diff --git a/2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg b/2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg new file mode 100644 index 0000000..8e9ce21 Binary files /dev/null and b/2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg differ diff --git a/backend/import_wp.py b/backend/import_wp.py index 78c74cb..66a251e 100644 --- a/backend/import_wp.py +++ b/backend/import_wp.py @@ -137,7 +137,7 @@ async def run_master_import(): c_name = acf.get('navn_pa_hovedbane' if suffix == '' else 'navn_pa_sekundar_bane') or ('Hovedbanen' if suffix == '' else 'Bane 2') status = acf.get('banestatus' if suffix == '' else 'banestatus_sekundar_bane') if suffix == '_bane_to' and (status == 'finnes_ingen_bane_to' or not parse_int(acf.get('hull_1_par_bane_to'))): continue - course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt'))) + course_id = await conn.fetchval('INSERT INTO courses (facility_id, name, status, par, physical_hole_count, is_main_course, tee_boxes, architect) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id', fac_id, c_name, status, parse_int(acf.get('totalt_par' if suffix == '' else 'totalt_par_bane_to')), parse_int(acf.get('antall_hull')) if suffix == '' else None, (suffix == ''), json.dumps({"herrer": acf.get(f"utslag_herrer{suffix}"), "damer": acf.get(f"utslag_damer{suffix}")}), decode_html(acf.get('arkitekt'))) curr_len = 0 for h_num in range(1, 19): p = parse_int(acf.get(f'hull_{h_num}_par{suffix}')) diff --git a/backend/main.py b/backend/main.py index fe2f6d0..24fb2e6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -90,6 +90,11 @@ FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv( INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip() INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip() INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").strip() +FRONTEND_REVALIDATE_URL = os.getenv( + "FRONTEND_REVALIDATE_URL", + "http://frontend:3000/internal/revalidate-public", +).strip() +FRONTEND_REVALIDATE_SECRET = os.getenv("FRONTEND_REVALIDATE_SECRET", PUBLIC_SESSION_SECRET).strip() PUBLIC_FACILITIES_CACHE_TTLS = { "search": 900, @@ -189,14 +194,45 @@ def apply_public_cache_headers(response: Response, ttl_seconds: int) -> None: response.headers["Cache-Control"] = f"public, max-age=60, s-maxage={ttl}, stale-while-revalidate=60" -def invalidate_public_api_caches(*, include_place_pages: bool = False, include_site_pages: bool = False) -> None: - facilities_cache = getattr(app.state, "public_facilities_cache", None) - if isinstance(facilities_cache, dict): - facilities_cache.clear() +async def trigger_frontend_public_revalidation( + *, + include_facilities: bool, + include_place_pages: bool = False, + include_site_pages: bool = False, +) -> None: + if not FRONTEND_REVALIDATE_URL or not FRONTEND_REVALIDATE_SECRET: + return - detail_cache = getattr(app.state, "public_facility_detail_cache", None) - if isinstance(detail_cache, dict): - detail_cache.clear() + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + FRONTEND_REVALIDATE_URL, + headers={"x-teeoff-revalidate-secret": FRONTEND_REVALIDATE_SECRET}, + json={ + "includeFacilities": include_facilities, + "includePlacePages": include_place_pages, + "includeSitePages": include_site_pages, + }, + ) + response.raise_for_status() + except Exception as exc: + print(f"Advarsel: kunne ikke revalidere frontend-cache ({exc})") + + +def invalidate_public_api_caches( + *, + include_facilities: bool = True, + include_place_pages: bool = False, + include_site_pages: bool = False, +) -> None: + if include_facilities: + facilities_cache = getattr(app.state, "public_facilities_cache", None) + if isinstance(facilities_cache, dict): + facilities_cache.clear() + + detail_cache = getattr(app.state, "public_facility_detail_cache", None) + if isinstance(detail_cache, dict): + detail_cache.clear() if include_place_pages: place_page_cache = getattr(app.state, "public_place_page_cache", None) @@ -208,6 +244,19 @@ def invalidate_public_api_caches(*, include_place_pages: bool = False, include_s if isinstance(site_page_cache, dict): site_page_cache.clear() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + loop.create_task( + trigger_frontend_public_revalidation( + include_facilities=include_facilities, + include_place_pages=include_place_pages, + include_site_pages=include_site_pages, + ) + ) + def get_configured_public_base_url() -> str: for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"): @@ -960,8 +1009,8 @@ FACILITY_VIEW_SEARCH_FIELDS = { 'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker', 'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description', 'footnote', 'footnote_updated_at', - 'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', - 'course_statuses', 'weather_forecast', + 'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', 'total_hole_count', + 'main_physical_hole_count', 'total_physical_hole_count', 'course_statuses', 'weather_forecast', } FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | { 'has_golfpakker', @@ -1819,6 +1868,10 @@ async def ensure_public_query_indexes(conn) -> None: CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx ON courses (facility_id, is_main_course DESC, id ASC) """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS courses_facility_id_visible_idx + ON courses (facility_id, is_visible, is_main_course DESC, id ASC) + """) await conn.execute(""" CREATE INDEX IF NOT EXISTS holes_course_id_idx ON holes (course_id) @@ -1855,6 +1908,14 @@ async def get_table_columns(conn, table_name: str, schema_name: str = "public") async def ensure_scorecard_tables(conn) -> None: + await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE") + await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS physical_hole_count INTEGER") + await conn.execute("ALTER TABLE courses ADD COLUMN IF NOT EXISTS include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE") + await conn.execute(""" + UPDATE courses + SET include_in_physical_hole_total = TRUE + WHERE include_in_physical_hole_total IS NULL + """) await conn.execute(""" CREATE TABLE IF NOT EXISTS tees ( id SERIAL PRIMARY KEY, @@ -1884,6 +1945,15 @@ async def ensure_scorecard_tables(conn) -> None: CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx ON hole_lengths (hole_id, tee_id) """) + await conn.execute(""" + UPDATE courses c + SET physical_hole_count = NULLIF(SUBSTRING(COALESCE(f.amenities->>'antall_hull', '') FROM '([0-9]+)'), '')::INTEGER + FROM facilities f + WHERE c.facility_id = f.id + AND c.is_main_course = TRUE + AND c.physical_hole_count IS NULL + AND COALESCE(f.amenities->>'antall_hull', '') <> '' + """) course_columns = await get_table_columns(conn, "courses") hole_columns = await get_table_columns(conn, "holes") @@ -2494,6 +2564,7 @@ async def build_facility_course_payloads( SELECT * FROM courses WHERE facility_id = $1 + AND COALESCE(is_visible, TRUE) = TRUE AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) ORDER BY is_main_course DESC, id ASC """, @@ -2650,6 +2721,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu for course in submitted_courses: normalized_course = dict(course) normalized_course['is_main_course'] = bool(course.get('is_main_course')) + normalized_course['is_visible'] = course.get('is_visible') is not False + normalized_course['include_in_physical_hole_total'] = course.get('include_in_physical_hole_total') is not False normalized_courses.append(normalized_course) if normalized_courses: @@ -2670,6 +2743,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu holes = [hole for hole in (course.get('holes') or []) if hole] tees = [tee for tee in (course.get('tees') or []) if tee] hole_count = len(holes) or None + physical_hole_count = parse_optional_int(course.get('physical_hole_count')) + include_in_physical_hole_total = course.get('include_in_physical_hole_total') is not False course_par = parse_optional_int(course.get('par')) submitted_course_length_meters = parse_optional_int(course.get('length_meters')) @@ -2684,27 +2759,51 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu valid_until = None if course_id: - await conn.execute(""" - UPDATE courses - SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5, - status=$6, is_main_course=$7, slope_valid_until=$8 - WHERE id=$9 AND facility_id=$10 - """, - course.get('name'), hole_count, course_par, submitted_course_length_meters, - course.get('architect'), course.get('status'), course.get('is_main_course'), - valid_until, course_id, facility_id) + if "is_visible" in course_columns: + await conn.execute(""" + UPDATE courses + SET name=$1, holes=$2, physical_hole_count=$3, par=$4, length_meters=$5, architect=$6, + status=$7, is_main_course=$8, slope_valid_until=$9, is_visible=$10, include_in_physical_hole_total=$11 + WHERE id=$12 AND facility_id=$13 + """, + course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + valid_until, course.get('is_visible'), include_in_physical_hole_total, course_id, facility_id) + else: + await conn.execute(""" + UPDATE courses + SET name=$1, holes=$2, physical_hole_count=$3, par=$4, length_meters=$5, architect=$6, + status=$7, is_main_course=$8, slope_valid_until=$9, include_in_physical_hole_total=$10 + WHERE id=$11 AND facility_id=$12 + """, + course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + valid_until, include_in_physical_hole_total, course_id, facility_id) else: - course_id = await conn.fetchval(""" - INSERT INTO courses ( - facility_id, name, holes, par, length_meters, architect, - status, is_main_course, slope_valid_until - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id - """, - facility_id, course.get('name'), hole_count, course_par, submitted_course_length_meters, - course.get('architect'), course.get('status'), course.get('is_main_course'), - valid_until) + if "is_visible" in course_columns: + course_id = await conn.fetchval(""" + INSERT INTO courses ( + facility_id, name, holes, physical_hole_count, par, length_meters, architect, + status, is_main_course, slope_valid_until, is_visible, include_in_physical_hole_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id + """, + facility_id, course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + valid_until, course.get('is_visible'), include_in_physical_hole_total) + else: + course_id = await conn.fetchval(""" + INSERT INTO courses ( + facility_id, name, holes, physical_hole_count, par, length_meters, architect, + status, is_main_course, slope_valid_until, include_in_physical_hole_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id + """, + facility_id, course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters, + course.get('architect'), course.get('status'), course.get('is_main_course'), + valid_until, include_in_physical_hole_total) retained_course_ids.append(int(course_id)) @@ -4435,6 +4534,13 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non SELECT * FROM facilities WHERE is_published IS DISTINCT FROM FALSE + AND EXISTS ( + SELECT 1 + FROM courses c + WHERE c.facility_id = facilities.id + AND COALESCE(c.is_visible, TRUE) = TRUE + AND c.status != 'finnes_ingen_bane_to' + ) ) """ course_statuses_cte = """ @@ -4452,6 +4558,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non FROM courses c JOIN published_facilities pf ON pf.id = c.facility_id WHERE c.status != 'finnes_ingen_bane_to' + AND COALESCE(c.is_visible, TRUE) = TRUE GROUP BY c.facility_id ) """ @@ -4511,6 +4618,30 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non FROM courses c JOIN holes h ON h.course_id = c.id JOIN published_facilities pf ON pf.id = c.facility_id + WHERE COALESCE(c.is_visible, TRUE) = TRUE + GROUP BY c.facility_id + ) + """ + main_course_meta_cte = """ + main_course_meta AS ( + SELECT DISTINCT ON (c.facility_id) + c.facility_id, + COALESCE(c.physical_hole_count, c.holes) AS main_physical_hole_count + FROM courses c + JOIN published_facilities pf ON pf.id = c.facility_id + WHERE COALESCE(c.is_visible, TRUE) = TRUE + ORDER BY c.facility_id ASC, c.is_main_course DESC, c.id ASC + ) + """ + physical_hole_totals_cte = """ + physical_hole_totals AS ( + SELECT + c.facility_id, + SUM(COALESCE(c.physical_hole_count, c.holes)) AS total_physical_hole_count + FROM courses c + JOIN published_facilities pf ON pf.id = c.facility_id + WHERE COALESCE(c.include_in_physical_hole_total, TRUE) = TRUE + AND COALESCE(c.physical_hole_count, c.holes) IS NOT NULL GROUP BY c.facility_id ) """ @@ -4526,6 +4657,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non JOIN courses c ON c.id = t.course_id AND c.id = h.course_id JOIN published_facilities pf ON pf.id = c.facility_id WHERE hl.length_meters BETWEEN 30 AND 900 + AND COALESCE(c.is_visible, TRUE) = TRUE GROUP BY c.facility_id ) """ @@ -4543,7 +4675,10 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non WITH {published_facilities_cte}, {course_statuses_cte}, - {weather_compact_cte} + {weather_compact_cte}, + {main_course_meta_cte}, + {physical_hole_totals_cte}, + {hole_counts_cte} SELECT pf.id, pf.slug, @@ -4579,9 +4714,15 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non pf.footnote_updated_at, pf.status_updated_at, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, + COALESCE(hc.total_hole_count, 0) AS total_hole_count, + mcm.main_physical_hole_count, + pht.total_physical_hole_count, COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast FROM published_facilities pf LEFT JOIN course_statuses cs ON cs.facility_id = pf.id + LEFT JOIN hole_counts hc ON hc.facility_id = pf.id + LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id + LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id LEFT JOIN weather_compact wc ON wc.facility_id = pf.id ORDER BY pf.name ASC """, @@ -4595,6 +4736,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non {published_facilities_cte}, {course_statuses_cte}, {weather_compact_cte}, + {main_course_meta_cte}, + {physical_hole_totals_cte}, {hole_counts_cte}, {hole_lengths_cte} SELECT @@ -4635,6 +4778,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non pf.status_updated_at, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, COALESCE(hc.total_hole_count, 0) AS total_hole_count, + mcm.main_physical_hole_count, + pht.total_physical_hole_count, COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts, hl.shortest_hole_meters, hl.longest_hole_meters, @@ -4643,6 +4788,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non LEFT JOIN course_statuses cs ON cs.facility_id = pf.id LEFT JOIN weather_compact wc ON wc.facility_id = pf.id LEFT JOIN hole_counts hc ON hc.facility_id = pf.id + LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id + LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id ORDER BY pf.name ASC """, @@ -4746,12 +4893,16 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non {published_facilities_cte}, {course_statuses_cte}, {weather_full_cte}, + {main_course_meta_cte}, + {physical_hole_totals_cte}, {hole_counts_cte}, {hole_lengths_cte} SELECT pf.*, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, COALESCE(hc.total_hole_count, 0) AS total_hole_count, + mcm.main_physical_hole_count, + pht.total_physical_hole_count, COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts, hl.shortest_hole_meters, hl.longest_hole_meters, @@ -4759,6 +4910,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non FROM published_facilities pf LEFT JOIN course_statuses cs ON cs.facility_id = pf.id LEFT JOIN hole_counts hc ON hc.facility_id = pf.id + LEFT JOIN main_course_meta mcm ON mcm.facility_id = pf.id + LEFT JOIN physical_hole_totals pht ON pht.facility_id = pf.id LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id LEFT JOIN weather_full wf ON wf.facility_id = pf.id ORDER BY pf.name ASC @@ -5176,7 +5329,7 @@ async def update_admin_site_page(page_key: str, request: SitePageUpsertRequest): config = SITE_PAGE_CONFIGS.get(normalized_key) or {} path = str(config.get("path") or "").strip() - invalidate_public_api_caches(include_site_pages=True) + invalidate_public_api_caches(include_facilities=False, include_site_pages=True) if path: schedule_indexnow_submission( collect_page_indexnow_urls([path]), @@ -5209,7 +5362,7 @@ async def update_admin_place_page(slug: str, request: PlacePageUpsertRequest): normalize_optional_text(request.meta_description), ) - invalidate_public_api_caches(include_place_pages=True) + invalidate_public_api_caches(include_facilities=False, include_place_pages=True) schedule_indexnow_submission( collect_page_indexnow_urls([f"/sted/{normalized_slug}"]), reason="admin place page upsert", @@ -5271,6 +5424,7 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe "meninger": "/meninger", "simulatorer": "/simulatorer", } + invalidate_public_api_caches(include_facilities=False, include_site_pages=True) schedule_indexnow_submission( collect_page_indexnow_urls([page_path_map[normalized_key]]), reason="admin site page seo upsert", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5b8c57b..6e5b233 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -65,6 +65,7 @@ services: command: npm start environment: NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} + PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET} volumes: - ./frontend/public:/app/public depends_on: diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 0a991db..2bfb596 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -5,7 +5,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState, type CSSProperties } from "react"; -import { getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData"; +import { getFacilityVisibleHoleValue, getPublicCourseDisplayName, type EnrichedFacility } from "@/app/facilityData"; type SortMethod = "updated" | "dist" | "alpha"; type Variant = "home" | "catalog"; @@ -42,6 +42,7 @@ type Facility = { footnote?: string | null; footnote_updated_at?: string | null; status_updated_at?: string | null; + total_hole_count?: number | null; amenities?: unknown; golfamore_data?: unknown; nsg_data?: unknown; @@ -378,14 +379,15 @@ const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label: }; 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; + return normalizeText(holeValue) === normalizeText(filterValue); +}; + +const getHoleFilterLabel = (value: string) => { + const trimmedValue = String(value || "").trim(); + if (!trimmedValue) return ""; + if (/^\d+$/.test(trimmedValue)) return `${trimmedValue} hull`; + return trimmedValue; }; const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { @@ -638,7 +640,7 @@ export default function FacilitySearch({ const countySlug = slugify(facility.county || ""); const regions = getFacilityRegions(facility.county || ""); - const holeValue = String(amenities.antall_hull || "").trim(); + const holeValue = getFacilityVisibleHoleValue(facility); const primaryStatus = getPrimaryStatus(statuses); const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status)); const hasGolfamore = @@ -771,6 +773,35 @@ export default function FacilitySearch({ weatherDayFilter, ]); + const holeFilterOptions = useMemo(() => { + const seen = new Set(); + const options = (Array.isArray(initialFacilities) ? initialFacilities : []) + .map((facility) => getFacilityVisibleHoleValue(facility).trim()) + .filter((value) => { + if (!value) return false; + const normalizedValue = normalizeText(value); + if (!normalizedValue || seen.has(normalizedValue)) return false; + seen.add(normalizedValue); + return true; + }) + .sort((a, b) => { + const aIsNumeric = /^\d+$/.test(a); + const bIsNumeric = /^\d+$/.test(b); + + if (aIsNumeric && bIsNumeric) { + return Number(a) - Number(b); + } + if (aIsNumeric) return -1; + if (bIsNumeric) return 1; + return a.localeCompare(b, "nb"); + }); + + return options.map((value) => ({ + value, + label: getHoleFilterLabel(value), + })); + }, [initialFacilities]); + const filtersCount = [ areaFilter, statusFilter, @@ -878,11 +909,11 @@ export default function FacilitySearch({ - - - - - + {holeFilterOptions.map((option) => ( + + ))} ({ + ...course, + is_visible: course?.is_visible !== false, + })) + : [], videos: normalizeFacilityVideos(initialData?.videos), }); const [activeTab, setActiveTab] = useState('generelt'); @@ -657,10 +662,13 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini _clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: `Ny bane ${existingCourses.length + 1}`, status: 'ukjent', + physical_hole_count: '', + include_in_physical_hole_total: true, par: '', length_meters: '', architect: '', is_main_course: existingCourses.length === 0, + is_visible: true, slope_valid_until: '', tees: [], holes: Array.from({ length: 18 }, (_, index) => ({ @@ -1466,8 +1474,8 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini 0 ? Number(course.physical_hole_count) : (course.holes?.length || 0)} hull`} >
@@ -1481,8 +1489,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini /> Hovedbane - - {course.is_main_course ? 'Hovedbane' : 'Sekundærbane'} + + + {course.is_visible === false ? 'Skjult' : course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
@@ -1526,6 +1549,36 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini }); }} />
+
+ + { + updateCourses((courses) => { + const nextCourses = [...courses]; + nextCourses[cIdx] = {...course, physical_hole_count: e.target.value === "" ? "" : Number(e.target.value)}; + return nextCourses; + }); + }} /> +
+
+ +
{ diff --git a/frontend/src/app/api/admin/revalidate-public/route.ts b/frontend/src/app/api/admin/revalidate-public/route.ts new file mode 100644 index 0000000..c001052 --- /dev/null +++ b/frontend/src/app/api/admin/revalidate-public/route.ts @@ -0,0 +1,92 @@ +import { revalidatePath, revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; +import { PUBLIC_FACILITIES_CACHE_TAG } from "@/app/publicFacilities"; +import { SITE_PAGE_CACHE_TAG } from "@/app/sitePages"; +import { SITE_PAGE_SEO_CACHE_TAG } from "@/app/pageSeo"; + +export const runtime = "nodejs"; + +type RevalidatePayload = { + includeFacilities?: boolean; + includePlacePages?: boolean; + includeSitePages?: boolean; +}; + +const FACILITY_PAGE_PATHS = ["/", "/golfbaner", "/medlemskap", "/vtg"]; +const SITE_PAGE_PATHS = ["/golfbaner", "/vtg", "/medlemskap", "/banebesok", "/meninger", "/simulatorer"]; + +function getExpectedSecret(): string { + return String(process.env.PUBLIC_SESSION_SECRET || "").trim(); +} + +function isAuthorized(request: Request): boolean { + const expectedSecret = getExpectedSecret(); + if (!expectedSecret) { + return false; + } + + const suppliedSecret = String(request.headers.get("x-teeoff-revalidate-secret") || "").trim(); + return suppliedSecret === expectedSecret; +} + +function revalidateFacilityPages(includePlacePages: boolean): void { + revalidateTag(PUBLIC_FACILITIES_CACHE_TAG, "max"); + + for (const path of FACILITY_PAGE_PATHS) { + revalidatePath(path); + } + + revalidatePath("/golfbaner/[slug]", "page"); + revalidatePath("/sted/[slug]", "page"); + + if (includePlacePages) { + revalidatePath("/sted/[slug]", "page"); + } +} + +function revalidateSitePageContent(): void { + revalidateTag(SITE_PAGE_CACHE_TAG, "max"); + revalidateTag(SITE_PAGE_SEO_CACHE_TAG, "max"); + + for (const path of SITE_PAGE_PATHS) { + revalidatePath(path); + } +} + +function revalidatePlacePagesOnly(): void { + revalidatePath("/sted/[slug]", "page"); +} + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let payload: RevalidatePayload = {}; + try { + payload = (await request.json()) as RevalidatePayload; + } catch { + payload = {}; + } + + const includeFacilities = payload.includeFacilities !== false; + const includePlacePages = payload.includePlacePages === true; + const includeSitePages = payload.includeSitePages === true; + + if (includeFacilities) { + revalidateFacilityPages(includePlacePages); + } else if (includePlacePages) { + revalidatePlacePagesOnly(); + } + + if (includeSitePages) { + revalidateSitePageContent(); + } + + return NextResponse.json({ + revalidated: true, + includeFacilities, + includePlacePages, + includeSitePages, + }); +} diff --git a/frontend/src/app/api/search/menu/route.ts b/frontend/src/app/api/search/menu/route.ts new file mode 100644 index 0000000..b38dc70 --- /dev/null +++ b/frontend/src/app/api/search/menu/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import type { FacilityRecord } from "@/app/facilityData"; +import { fetchPublicFacilities } from "@/app/publicFacilities"; +import type { EditorialArticle } from "@/content/editorialArticles"; +import { getCourseVisits, getOpinionArticles } from "@/content/courseVisits"; +import { buildMenuSearchHref, normalizeMenuSearchText, type MenuSearchItem } from "@/lib/menuSearch"; + +const REVALIDATE_SECONDS = 900; + +export const runtime = "nodejs"; + +function buildFacilitySearchItem(facility: FacilityRecord): MenuSearchItem | null { + const slug = String(facility?.slug || "").trim(); + const title = String(facility?.name || "").trim(); + if (!slug || !title) return null; + + const subtitle = [facility.city, facility.county].map((value) => String(value || "").trim()).filter(Boolean).join(", "); + const searchTerms = [ + title, + slug.replace(/-/g, " "), + facility.city, + facility.county, + facility.banetype, + "golfbane", + "golfbaner", + "baneprofil", + ] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(" "); + + return { + title, + href: `/golfbaner/${slug}`, + kind: "facility", + label: "Bane", + subtitle: subtitle || undefined, + titleText: normalizeMenuSearchText(title), + searchText: normalizeMenuSearchText(searchTerms), + priority: 120, + }; +} + +function buildArticleSearchItem(article: EditorialArticle): MenuSearchItem | null { + const slug = String(article?.slug || "").trim(); + const title = String(article?.title || "").trim(); + if (!slug || !title) return null; + + const label = article.section === "meninger" ? "Meninger" : "Banebesøk"; + const subtitle = + String(article.facilityName || "").trim() || + String(article.locationLabel || "").trim() || + undefined; + const searchTerms = [ + title, + slug.replace(/-/g, " "), + article.facilityName, + article.locationLabel, + article.eyebrow, + label, + "artikkel", + ] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(" "); + + return { + title, + href: buildMenuSearchHref(article.section, slug), + kind: "article", + label, + subtitle, + section: article.section, + titleText: normalizeMenuSearchText(title), + searchText: normalizeMenuSearchText(searchTerms), + priority: article.section === "banebesok" ? 72 : 64, + }; +} + +export async function GET() { + const [facilitiesResult, courseVisitsResult, opinionsResult] = await Promise.allSettled([ + fetchPublicFacilities("search", REVALIDATE_SECONDS, { + allowEmpty: true, + }), + getCourseVisits(), + getOpinionArticles(), + ]); + + const items = new Map(); + + if (facilitiesResult.status === "fulfilled") { + for (const facility of facilitiesResult.value) { + const item = buildFacilitySearchItem(facility); + if (item) items.set(item.href, item); + } + } + + if (courseVisitsResult.status === "fulfilled") { + for (const article of courseVisitsResult.value) { + const item = buildArticleSearchItem(article); + if (item) items.set(item.href, item); + } + } + + if (opinionsResult.status === "fulfilled") { + for (const article of opinionsResult.value) { + const item = buildArticleSearchItem(article); + if (item) items.set(item.href, item); + } + } + + const response = NextResponse.json({ + items: Array.from(items.values()), + }); + + response.headers.set( + "Cache-Control", + `public, max-age=0, s-maxage=${REVALIDATE_SECONDS}, stale-while-revalidate=86400`, + ); + + return response; +} diff --git a/frontend/src/app/facilityData.ts b/frontend/src/app/facilityData.ts index 8461b84..945b967 100755 --- a/frontend/src/app/facilityData.ts +++ b/frontend/src/app/facilityData.ts @@ -3,6 +3,7 @@ import { STATUS_MAP } from "@/config/constants"; export type CourseStatus = { status?: string; name?: string; + is_visible?: boolean; }; export type FacilityRecord = { @@ -36,6 +37,8 @@ export type FacilityRecord = { greenfee?: unknown; standard_medlemskap?: number | null; total_hole_count?: number | null; + main_physical_hole_count?: number | null; + total_physical_hole_count?: number | null; hole_par_counts?: unknown; shortest_hole_meters?: number | null; longest_hole_meters?: number | null; @@ -259,6 +262,58 @@ export const getPrimaryStatus = (statuses: Array<{ status?: string }>) => { return "ukjent"; }; +export const getFacilityVisibleHoleCount = ( + facility: Pick +) => { + if ( + typeof facility.total_physical_hole_count === "number" && + Number.isFinite(facility.total_physical_hole_count) && + facility.total_physical_hole_count > 0 + ) { + return facility.total_physical_hole_count; + } + + if (typeof facility.total_hole_count === "number" && Number.isFinite(facility.total_hole_count) && facility.total_hole_count > 0) { + return facility.total_hole_count; + } + + const amenities = parseJson>(facility.amenities, {}); + const holeCategory = getHoleCategory(amenities.antall_hull); + if (holeCategory === "27+") return 27; + if (holeCategory === "18") return 18; + if (holeCategory === "9") return 9; + if (holeCategory === "6") return 6; + return 0; +}; + +export const getFacilityVisibleHoleValue = ( + facility: Pick +) => { + if ( + typeof facility.total_physical_hole_count === "number" && + Number.isFinite(facility.total_physical_hole_count) && + facility.total_physical_hole_count > 0 + ) { + return String(facility.total_physical_hole_count); + } + + if ( + typeof facility.main_physical_hole_count === "number" && + Number.isFinite(facility.main_physical_hole_count) && + facility.main_physical_hole_count > 0 + ) { + return String(facility.main_physical_hole_count); + } + + const visibleHoleCount = getFacilityVisibleHoleCount(facility); + if (visibleHoleCount > 0) { + return String(visibleHoleCount); + } + + const amenities = parseJson>(facility.amenities, {}); + return String(amenities.antall_hull || "").trim(); +}; + export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status"; export const formatUpdatedDate = (value: string | null | undefined) => { @@ -304,7 +359,7 @@ export const enrichFacilities = ( Array.isArray(rawStatuses) && rawStatuses.length > 0 ? rawStatuses : [{ status: "ukjent", name: "" }]; - const holeValue = String(amenities.antall_hull || "").trim(); + const holeValue = getFacilityVisibleHoleValue(facility); const countySlug = slugify(facility.county || ""); const regions = getFacilityRegions(facility.county || ""); const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; @@ -528,6 +583,61 @@ const getHoleCategory = (value: unknown) => { return null; }; +const getFacilityPlaceHoleCategory = ( + facility: Pick +) => { + if ( + typeof facility.total_physical_hole_count === "number" && + Number.isFinite(facility.total_physical_hole_count) && + facility.total_physical_hole_count > 0 + ) { + return getHoleCategory(String(facility.total_physical_hole_count)); + } + + if ( + typeof facility.main_physical_hole_count === "number" && + Number.isFinite(facility.main_physical_hole_count) && + facility.main_physical_hole_count > 0 + ) { + return getHoleCategory(String(facility.main_physical_hole_count)); + } + + const amenities = parseJson>(facility.amenities, {}); + const amenityCategory = getHoleCategory(amenities.antall_hull); + if (amenityCategory) { + return amenityCategory; + } + + return getHoleCategory(getFacilityVisibleHoleValue(facility)); +}; + +const getFacilityPlaceHoleCount = ( + facility: Pick +) => { + if ( + typeof facility.total_physical_hole_count === "number" && + Number.isFinite(facility.total_physical_hole_count) && + facility.total_physical_hole_count > 0 + ) { + return facility.total_physical_hole_count; + } + + if ( + typeof facility.main_physical_hole_count === "number" && + Number.isFinite(facility.main_physical_hole_count) && + facility.main_physical_hole_count > 0 + ) { + return facility.main_physical_hole_count; + } + + const holeCategory = getFacilityPlaceHoleCategory(facility); + if (holeCategory === "27+") return 27; + if (holeCategory === "18") return 18; + if (holeCategory === "9") return 9; + if (holeCategory === "6") return 6; + return getFacilityVisibleHoleCount(facility); +}; + const getPrimetimeGreenfee = (facility: FacilityRecord) => { const rows = parseJson>>(facility.greenfee, []); if (!Array.isArray(rows) || rows.length === 0) return null; @@ -626,19 +736,7 @@ const getPrimetimeGreenfee = (facility: FacilityRecord) => { const bestPrice = Math.max(...selectionPool.map((candidate) => candidate.price)); const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice); const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest); - const amenities = parseJson>(facility.amenities, {}); - const totalHoleCount = (() => { - if (typeof facility.total_hole_count === "number" && Number.isFinite(facility.total_hole_count)) { - return facility.total_hole_count; - } - - const holeCategory = getHoleCategory(amenities.antall_hull); - if (holeCategory === "27+") return 27; - if (holeCategory === "18") return 18; - if (holeCategory === "9") return 9; - if (holeCategory === "6") return 6; - return 0; - })(); + const totalHoleCount = getFacilityVisibleHoleCount(facility); const finalPriceCandidate = bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0]; const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true; @@ -727,14 +825,14 @@ export const buildPlaceStats = (facilities: EnrichedFacility[]): PlaceStats => { return { facilityCount: relevantFacilities.length, - totalGolfHoles: relevantFacilities.reduce((sum, facility) => sum + (Number(facility.total_hole_count) || 0), 0), + totalGolfHoles: relevantFacilities.reduce((sum, facility) => sum + getFacilityPlaceHoleCount(facility), 0), totalCourseCount: courseTotals.total, openCourseCount: courseTotals.open, openNowCount: relevantFacilities.filter((facility) => OPEN_NOW_STATUSES.has(normalizeStatus(facility.primaryStatus))).length, - hole18Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "18").length, - hole9Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "9").length, - hole6Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "6").length, - hole27PlusCount: relevantFacilities.filter((facility) => getHoleCategory(parseJson>(facility.amenities, {}).antall_hull) === "27+").length, + hole18Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "18").length, + hole9Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "9").length, + hole6Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "6").length, + hole27PlusCount: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "27+").length, par3HoleCount: holeParTotals.par3, par4HoleCount: holeParTotals.par4, par5HoleCount: holeParTotals.par5, diff --git a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx index fbb7100..e721145 100644 --- a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx +++ b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx @@ -34,6 +34,19 @@ const getHoleLength = (hole: any, teeKey: string) => { return typeof value === "number" || typeof value === "string" ? value : null; }; +const toNumber = (value: unknown) => { + if (typeof value === "number") { + return Number.isFinite(value) ? value : NaN; + } + + if (typeof value === "string") { + const normalized = value.replace(",", ".").trim(); + return normalized ? Number(normalized) : NaN; + } + + return NaN; +}; + export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) { const [hcp, setHcp] = useState("15.0"); const [gender, setGender] = useState('herrer'); @@ -78,30 +91,51 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null; const activeTee = selectedColumn?.tee || null; const selectedTeeLabel = selectedColumn?.label || "Valgt utslag"; + const coursePar = allHoles.reduce((sum: number, hole: any) => sum + (Number(hole?.par) || 0), 0) || Number(course.par) || 0; + const strokeIndexOrder: number[] = Array.from( + new Set( + allHoles + .map((hole: any) => Number(hole?.hcp_index)) + .filter((value: number) => Number.isInteger(value) && value > 0) + ) + ).sort((a, b) => a - b); + const strokeRankByIndex = new Map( + strokeIndexOrder.map((value: number, index: number) => [value, index + 1] as const) + ); + const strokesPerCycle = strokeIndexOrder.length || allHoles.length || 18; let playingHandicap = 0; if (activeTee && hcp) { - const exactHcp = Number(hcp.replace(',', '.')); + const exactHcp = toNumber(hcp); const slope = Number( gender === 'damer' ? activeTee.slope_women || activeTee.slope_men || 113 : activeTee.slope_men || activeTee.slope_women || 113 ); - const courseRating = Number( - String( - gender === 'damer' - ? activeTee.cr_women || activeTee.cr_men || course.par - : activeTee.cr_men || activeTee.cr_women || course.par - ).replace(',', '.') + const courseRating = toNumber( + gender === 'damer' + ? activeTee.cr_women || activeTee.cr_men || coursePar + : activeTee.cr_men || activeTee.cr_women || coursePar ); - playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - course.par)); + + if (Number.isFinite(exactHcp) && Number.isFinite(courseRating)) { + playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - coursePar)); + } } const getExtraStrokes = (hcpIndex: number) => { - if (!hcpIndex || isNaN(playingHandicap)) return 0; - const base = Math.floor(playingHandicap / 18); - const rem = playingHandicap % 18; - return base + (hcpIndex <= rem ? 1 : 0); + if (!Number.isFinite(playingHandicap)) return 0; + + const normalizedHcpIndex = Number(hcpIndex); + const strokeRank = strokeRankByIndex.get(normalizedHcpIndex); + if (!strokeRank || strokesPerCycle <= 0) return 0; + + const handicapMagnitude = Math.abs(playingHandicap); + const base = Math.floor(handicapMagnitude / strokesPerCycle); + const remainder = handicapMagnitude % strokesPerCycle; + const strokeCount = base + (strokeRank <= remainder ? 1 : 0); + + return playingHandicap < 0 ? -strokeCount : strokeCount; }; const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0); @@ -132,7 +166,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour

Mottatt

-

{extra > 0 ? `+${extra}` : '-'}

+

{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}

Din Par

@@ -171,7 +205,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour

{courseDisplayName}

) : null}

- Par {course.par} • {course.length_meters || '--'} meter + Par {coursePar} • {course.length_meters || '--'} meter

Rating utløper: {slopeExpiry} @@ -250,7 +284,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour {hole.hole_number} {hole.par} {hole.hcp_index} - {extra > 0 ? `+${extra}` : '-'} + {extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'} {hole.par + extra} {activeColumns.map((column) => ( @@ -277,7 +311,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour {hole.hole_number} {hole.par} {hole.hcp_index} - {extra > 0 ? `+${extra}` : '-'} + {extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'} {hole.par + extra} {activeColumns.map((column) => ( diff --git a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx index 86d1b3e..30eb76f 100644 --- a/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx +++ b/frontend/src/app/golfbaner/[slug]/FacilityDetailView.tsx @@ -341,8 +341,56 @@ export default function FacilityDetailView({ return parseSharedJson(val, fallback); }; + const getCourseHoleCount = (course: any) => { + if ( + typeof course?.physical_hole_count === "number" && + Number.isFinite(course.physical_hole_count) && + course.physical_hole_count > 0 + ) { + return course.physical_hole_count; + } + + if (Array.isArray(course?.holes)) { + return course.holes.filter(Boolean).length; + } + + if (typeof course?.holes === "number" && Number.isFinite(course.holes)) { + return course.holes; + } + + return 0; + }; + const rawCourses = parseJson(facility.courses, []); - const activeCourses = Array.isArray(rawCourses) ? rawCourses.filter((c: any) => c.holes && (typeof c.holes === 'string' || c.holes.length > 0)) : []; + const activeCourses = Array.isArray(rawCourses) + ? rawCourses.filter((course: any) => course?.is_visible !== false && getCourseHoleCount(course) > 0) + : []; + const visibleHoleCount = activeCourses.reduce((sum: number, course: any) => sum + getCourseHoleCount(course), 0); + const totalIncludedPhysicalHoleCount = Array.isArray(rawCourses) + ? rawCourses.reduce((sum: number, course: any) => { + if (!course || course.include_in_physical_hole_total === false) return sum; + const courseHoleCount = getCourseHoleCount(course); + return courseHoleCount > 0 ? sum + courseHoleCount : sum; + }, 0) + : 0; + const amenityHoleDisplay = (() => { + const raw = String(parseJson(facility.amenities, {}).antall_hull || "").trim(); + if (!raw) return null; + if (!raw.includes("+")) return raw; + + const values = (raw.match(/\d+/g) || []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0); + if (values.length === 0) return raw; + + return String(values.reduce((sum, value) => sum + value, 0)); + })(); + const facilityPhysicalHoleCount = + typeof facility.total_physical_hole_count === "number" && + Number.isFinite(facility.total_physical_hole_count) && + facility.total_physical_hole_count > 0 + ? facility.total_physical_hole_count + : totalIncludedPhysicalHoleCount || visibleHoleCount; const amenities = parseJson(facility.amenities, {}); const camperParking = String(facility.camper_parking || "").trim(); const galleryRaw = parseJson(facility.gallery, []); @@ -417,7 +465,7 @@ export default function FacilityDetailView({ hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null, { id: 'prices', label: 'Priser', showOnMobile: true }, hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null, - { id: 'scorecards', label: 'Scorekort', showOnMobile: true }, + activeCourses.length > 0 ? { id: 'scorecards', label: 'Scorekort', showOnMobile: true } : null, ].filter( (item): item is { id: string; label: string; showOnMobile: boolean } => Boolean(item) ); @@ -725,7 +773,7 @@ export default function FacilityDetailView({

Banen

-
Hull:{amenities.antall_hull || '--'}
+
Hull:{facilityPhysicalHoleCount || amenityHoleDisplay || amenities.antall_hull || '--'}
Lengde:{facility.length_meters ? `${facility.length_meters}m` : '--'}
Sesong:{facility.season || '--'}
Byggeår:{facility.established_year || '--'}
@@ -1162,6 +1210,7 @@ export default function FacilityDetailView({ /> {/* 9. SCOREKORT SEKSJON */} + {activeCourses.length > 0 && (

Scorekort

@@ -1172,6 +1221,7 @@ export default function FacilityDetailView({ ))}
+ )} ("search", revalidate); + const safeData = await fetchPublicFacilities("search", 0); const collectionJsonLd = createCollectionPageJsonLd({ name: seo.title, description: seo.description, diff --git a/frontend/src/app/internal/revalidate-public/route.ts b/frontend/src/app/internal/revalidate-public/route.ts new file mode 100644 index 0000000..c001052 --- /dev/null +++ b/frontend/src/app/internal/revalidate-public/route.ts @@ -0,0 +1,92 @@ +import { revalidatePath, revalidateTag } from "next/cache"; +import { NextResponse } from "next/server"; +import { PUBLIC_FACILITIES_CACHE_TAG } from "@/app/publicFacilities"; +import { SITE_PAGE_CACHE_TAG } from "@/app/sitePages"; +import { SITE_PAGE_SEO_CACHE_TAG } from "@/app/pageSeo"; + +export const runtime = "nodejs"; + +type RevalidatePayload = { + includeFacilities?: boolean; + includePlacePages?: boolean; + includeSitePages?: boolean; +}; + +const FACILITY_PAGE_PATHS = ["/", "/golfbaner", "/medlemskap", "/vtg"]; +const SITE_PAGE_PATHS = ["/golfbaner", "/vtg", "/medlemskap", "/banebesok", "/meninger", "/simulatorer"]; + +function getExpectedSecret(): string { + return String(process.env.PUBLIC_SESSION_SECRET || "").trim(); +} + +function isAuthorized(request: Request): boolean { + const expectedSecret = getExpectedSecret(); + if (!expectedSecret) { + return false; + } + + const suppliedSecret = String(request.headers.get("x-teeoff-revalidate-secret") || "").trim(); + return suppliedSecret === expectedSecret; +} + +function revalidateFacilityPages(includePlacePages: boolean): void { + revalidateTag(PUBLIC_FACILITIES_CACHE_TAG, "max"); + + for (const path of FACILITY_PAGE_PATHS) { + revalidatePath(path); + } + + revalidatePath("/golfbaner/[slug]", "page"); + revalidatePath("/sted/[slug]", "page"); + + if (includePlacePages) { + revalidatePath("/sted/[slug]", "page"); + } +} + +function revalidateSitePageContent(): void { + revalidateTag(SITE_PAGE_CACHE_TAG, "max"); + revalidateTag(SITE_PAGE_SEO_CACHE_TAG, "max"); + + for (const path of SITE_PAGE_PATHS) { + revalidatePath(path); + } +} + +function revalidatePlacePagesOnly(): void { + revalidatePath("/sted/[slug]", "page"); +} + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let payload: RevalidatePayload = {}; + try { + payload = (await request.json()) as RevalidatePayload; + } catch { + payload = {}; + } + + const includeFacilities = payload.includeFacilities !== false; + const includePlacePages = payload.includePlacePages === true; + const includeSitePages = payload.includeSitePages === true; + + if (includeFacilities) { + revalidateFacilityPages(includePlacePages); + } else if (includePlacePages) { + revalidatePlacePagesOnly(); + } + + if (includeSitePages) { + revalidateSitePageContent(); + } + + return NextResponse.json({ + revalidated: true, + includeFacilities, + includePlacePages, + includeSitePages, + }); +} diff --git a/frontend/src/app/pageSeo.ts b/frontend/src/app/pageSeo.ts index b2049a5..0c34504 100644 --- a/frontend/src/app/pageSeo.ts +++ b/frontend/src/app/pageSeo.ts @@ -2,6 +2,13 @@ import { cache } from "react"; import { API_URL } from "@/config/constants"; import { resolveSeoDescription, resolveSeoTitle } from "@/app/seo"; +export const SITE_PAGE_SEO_CACHE_TAG = "site-page-seo"; + +export function getSitePageSeoCacheTags(pageKey: string): string[] { + const normalizedPageKey = String(pageKey || "").trim().toLowerCase() || "default"; + return [SITE_PAGE_SEO_CACHE_TAG, `${SITE_PAGE_SEO_CACHE_TAG}:${normalizedPageKey}`]; +} + export type SitePageSeoRecord = { page_key?: string; meta_title?: string | null; diff --git a/frontend/src/app/publicFacilities.ts b/frontend/src/app/publicFacilities.ts index f0e8f7d..0c568d3 100644 --- a/frontend/src/app/publicFacilities.ts +++ b/frontend/src/app/publicFacilities.ts @@ -1,17 +1,19 @@ +import { unstable_cache } from "next/cache"; import { API_URL } from "@/config/constants"; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -export async function fetchPublicFacilities( +export const PUBLIC_FACILITIES_CACHE_TAG = "public-facilities"; + +export function getPublicFacilitiesCacheTags(view: string): string[] { + const normalizedView = String(view || "").trim().toLowerCase() || "default"; + return [PUBLIC_FACILITIES_CACHE_TAG, `${PUBLIC_FACILITIES_CACHE_TAG}:${normalizedView}`]; +} + +async function fetchPublicFacilitiesUncached( view: string, - _revalidateSeconds: number, - { - allowEmpty = false, - attempts = 3, - }: { - allowEmpty?: boolean; - attempts?: number; - } = {}, + allowEmpty: boolean, + attempts: number, ): Promise { let lastError: Error | null = null; @@ -45,3 +47,26 @@ export async function fetchPublicFacilities( throw lastError ?? new Error("Kunne ikke hente anlegg"); } + +export async function fetchPublicFacilities( + view: string, + _revalidateSeconds: number, + { + allowEmpty = false, + attempts = 3, + }: { + allowEmpty?: boolean; + attempts?: number; + } = {}, +): Promise { + const normalizedView = String(view || "").trim().toLowerCase() || "default"; + const readFromCache = unstable_cache( + () => fetchPublicFacilitiesUncached(view, allowEmpty, attempts), + [`public-facilities:${normalizedView}`, allowEmpty ? "allow-empty" : "require-data"], + { + tags: getPublicFacilitiesCacheTags(view), + }, + ); + + return readFromCache(); +} diff --git a/frontend/src/app/sitePages.ts b/frontend/src/app/sitePages.ts index a3360c0..d8d43d4 100644 --- a/frontend/src/app/sitePages.ts +++ b/frontend/src/app/sitePages.ts @@ -1,6 +1,13 @@ import { cache } from "react"; import { API_URL } from "@/config/constants"; +export const SITE_PAGE_CACHE_TAG = "site-pages"; + +export function getSitePageCacheTags(pageKey: string): string[] { + const normalizedPageKey = String(pageKey || "").trim().toLowerCase() || "default"; + return [SITE_PAGE_CACHE_TAG, `${SITE_PAGE_CACHE_TAG}:${normalizedPageKey}`]; +} + export type SitePageRecord = { page_key: string; eyebrow?: string | null; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index d68bd96..cf389d8 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRef, useState } from "react"; +import HeaderSearch from "@/components/HeaderSearch"; type NavItem = { href: string; @@ -134,7 +135,7 @@ export default function Header() { /> -
+ +
+

Søk

+
+ +
+
)} diff --git a/frontend/src/components/HeaderSearch.tsx b/frontend/src/components/HeaderSearch.tsx new file mode 100644 index 0000000..6cff920 --- /dev/null +++ b/frontend/src/components/HeaderSearch.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; +import { + buildMenuSearchHref, + normalizeMenuSearchText, + rankMenuSearchItems, + type MenuSearchItem, +} from "@/lib/menuSearch"; + +type HeaderSearchProps = { + mobile?: boolean; + onNavigate?: () => void; + onOpen?: () => void; +}; + +type SearchFacilityRecord = { + slug?: string; + name?: string; + city?: string | null; + county?: string | null; + banetype?: string | null; +}; + +type SearchArticleRecord = { + section?: "banebesok" | "meninger" | string | null; + status?: string | null; + slug?: string; + title?: string; + facility_name?: string | null; + location_label?: string | null; + eyebrow?: string | null; +}; + +let menuSearchItemsPromise: Promise | null = null; + +async function fetchJsonArray(url: string) { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Søk svarte med ${response.status}`); + } + + const payload = (await response.json()) as unknown; + if (!Array.isArray(payload)) { + throw new Error("Søk svarte ikke med en liste"); + } + + return payload as T[]; +} + +function buildFacilitySearchItem(facility: SearchFacilityRecord): MenuSearchItem | null { + const slug = String(facility?.slug || "").trim(); + const title = String(facility?.name || "").trim(); + if (!slug || !title) return null; + + const subtitle = [facility.city, facility.county] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(", "); + const searchTerms = [ + title, + slug.replace(/-/g, " "), + facility.city, + facility.county, + facility.banetype, + "golfbane", + "golfbaner", + "baneprofil", + ] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(" "); + + return { + title, + href: `/golfbaner/${slug}`, + kind: "facility", + label: "Bane", + subtitle: subtitle || undefined, + titleText: normalizeMenuSearchText(title), + searchText: normalizeMenuSearchText(searchTerms), + priority: 120, + }; +} + +function buildArticleSearchItem(article: SearchArticleRecord): MenuSearchItem | null { + const section = article.section === "meninger" ? "meninger" : article.section === "banebesok" ? "banebesok" : null; + const slug = String(article?.slug || "").trim(); + const title = String(article?.title || "").trim(); + if (!section || !slug || !title) return null; + + const label = section === "meninger" ? "Meninger" : "Banebesøk"; + const subtitle = + String(article.facility_name || "").trim() || + String(article.location_label || "").trim() || + undefined; + const searchTerms = [ + title, + slug.replace(/-/g, " "), + article.facility_name, + article.location_label, + article.eyebrow, + label, + "artikkel", + ] + .map((value) => String(value || "").trim()) + .filter(Boolean) + .join(" "); + + return { + title, + href: buildMenuSearchHref(section, slug), + kind: "article", + label, + subtitle, + section, + titleText: normalizeMenuSearchText(title), + searchText: normalizeMenuSearchText(searchTerms), + priority: section === "banebesok" ? 72 : 64, + }; +} + +async function loadMenuSearchItems() { + if (!menuSearchItemsPromise) { + menuSearchItemsPromise = Promise.allSettled([ + fetchJsonArray("/api/facilities?view=search"), + fetchJsonArray("/api/articles?section=banebesok"), + fetchJsonArray("/api/articles?section=meninger"), + ]) + .then((results) => { + const items = new Map(); + + if (results[0].status === "fulfilled") { + for (const facility of results[0].value) { + const item = buildFacilitySearchItem(facility); + if (item) items.set(item.href, item); + } + } + + for (const result of results.slice(1)) { + if (result.status !== "fulfilled") continue; + + for (const article of result.value) { + const item = buildArticleSearchItem(article); + if (item) items.set(item.href, item); + } + } + + if (items.size === 0) { + throw new Error("Ingen søkedata tilgjengelig"); + } + + return Array.from(items.values()); + }) + .catch((error) => { + menuSearchItemsPromise = null; + throw error; + }); + } + + return menuSearchItemsPromise; +} + +function SearchIcon() { + return ( + + ); +} + +export default function HeaderSearch({ mobile = false, onNavigate, onOpen }: HeaderSearchProps) { + const router = useRouter(); + const containerRef = useRef(null); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [items, setItems] = useState(null); + const [isFocused, setIsFocused] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const isExpanded = mobile || isFocused || Boolean(query.trim()); + + const results = useMemo(() => { + if (!items || !query.trim()) return []; + return rankMenuSearchItems(items, query, 6); + }, [items, query]); + + useEffect(() => { + if (!results.length) { + setActiveIndex(0); + return; + } + + setActiveIndex((current) => (current >= results.length ? 0 : current)); + }, [results]); + + useEffect(() => { + if (mobile) return undefined; + + const handlePointerDown = (event: PointerEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsFocused(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [mobile]); + + const ensureItems = async () => { + if (items) return items; + setLoading(true); + setError(""); + + try { + const nextItems = await loadMenuSearchItems(); + setItems(nextItems); + return nextItems; + } catch { + setError("Søket kunne ikke lastes akkurat nå."); + return []; + } finally { + setLoading(false); + } + }; + + const handleOpen = () => { + setIsFocused(true); + onOpen?.(); + void ensureItems(); + window.setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }; + + const navigateTo = (href: string) => { + setQuery(""); + setIsFocused(false); + onNavigate?.(); + router.push(href); + }; + + const openOrSubmit = async () => { + if (!isExpanded) { + handleOpen(); + return; + } + + const nextItems = items ?? (await ensureItems()); + const rankedResults = rankMenuSearchItems(nextItems, query, 1); + + if (rankedResults[0]) { + navigateTo(rankedResults[0].href); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + await openOrSubmit(); + }; + + const handleKeyDown = async (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + if (!results.length) return; + setActiveIndex((current) => (current + 1) % results.length); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + if (!results.length) return; + setActiveIndex((current) => (current - 1 + results.length) % results.length); + return; + } + + if (event.key === "Escape") { + setIsFocused(false); + return; + } + + if (event.key === "Enter" && results[activeIndex]) { + event.preventDefault(); + navigateTo(results[activeIndex].href); + return; + } + + if (!items && event.key.length === 1) { + void ensureItems(); + } + }; + + const wrapperClassName = mobile ? "w-full" : "relative h-11 w-11"; + const shellClassName = mobile + ? "relative w-full" + : `absolute right-0 top-1/2 -translate-y-1/2 transition-[width] duration-200 ${isExpanded ? "w-[min(20rem,calc(100vw-2rem))]" : "w-11"}`; + const formClassName = mobile + ? "rounded-[1.35rem] border border-white/12 bg-white/8" + : "rounded-full border border-white/14 bg-white/8 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-md"; + const inputClassName = mobile + ? "w-full bg-transparent text-base font-bold text-white placeholder:text-white/58 focus:outline-none" + : `bg-transparent text-[13px] font-bold tracking-normal text-white placeholder:text-white/58 focus:outline-none transition-[width,opacity] duration-200 ${ + isExpanded ? "w-full opacity-100" : "pointer-events-none w-0 opacity-0" + }`; + const showDropdown = isFocused && (Boolean(query.trim()) || loading || Boolean(error)); + + return ( +
{ + const nextTarget = event.relatedTarget as Node | null; + if (!containerRef.current?.contains(nextTarget)) { + setIsFocused(false); + } + }} + > +
+
+
+ + setQuery(event.target.value)} + onFocus={handleOpen} + onKeyDown={handleKeyDown} + placeholder="Søk bane eller artikkel" + className={inputClassName} + aria-label="Søk etter bane eller artikkel" + autoComplete="off" + spellCheck={false} + /> +
+
+ + {showDropdown ? ( +
+
+ {loading ? ( +
Laster søk …
+ ) : error ? ( +
{error}
+ ) : results.length > 0 ? ( +
    + {results.map((result, index) => ( +
  • + +
  • + ))} +
+ ) : query.trim() ? ( +
Ingen treff på søket ditt.
+ ) : null} +
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/lib/menuSearch.ts b/frontend/src/lib/menuSearch.ts new file mode 100644 index 0000000..dc8964c --- /dev/null +++ b/frontend/src/lib/menuSearch.ts @@ -0,0 +1,135 @@ +export type MenuSearchSection = "banebesok" | "meninger"; + +export type MenuSearchKind = "facility" | "article"; + +export type MenuSearchItem = { + title: string; + href: string; + kind: MenuSearchKind; + label: string; + subtitle?: string; + section?: MenuSearchSection; + titleText: string; + searchText: string; + priority?: number; +}; + +const MENU_SEARCH_STOP_WORDS = new Set([ + "artikkel", + "artikler", + "av", + "bane", + "banebesok", + "baner", + "de", + "den", + "det", + "en", + "et", + "for", + "golf", + "golfbane", + "golfbaner", + "i", + "med", + "og", + "om", + "pa", + "til", +]); + +export const normalizeMenuSearchText = (value: unknown) => + String(value ?? "") + .replace(/[æøå]/gi, (char) => { + const normalized = char.toLowerCase(); + if (normalized === "æ") return "ae"; + if (normalized === "ø") return "o"; + if (normalized === "å") return "a"; + return normalized; + }) + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +export const tokenizeMenuSearchQuery = (query: string) => { + const tokens = normalizeMenuSearchText(query) + .split(/\s+/) + .filter(Boolean); + const filteredTokens = tokens.filter((token) => !MENU_SEARCH_STOP_WORDS.has(token)); + return filteredTokens.length > 0 ? filteredTokens : tokens; +}; + +export const buildMenuSearchHref = (section: MenuSearchSection, slug: string) => + section === "meninger" ? `/meninger/${slug}` : `/banebesok/${slug}`; + +export function rankMenuSearchItems(items: MenuSearchItem[], query: string, limit = 6) { + const normalizedQuery = normalizeMenuSearchText(query); + if (!normalizedQuery) return []; + + const queryTokens = tokenizeMenuSearchQuery(query); + + return items + .map((item) => ({ + item, + score: scoreMenuSearchItem(item, normalizedQuery, queryTokens), + })) + .filter((entry) => entry.score >= 0) + .sort((left, right) => { + if (left.score !== right.score) return right.score - left.score; + return left.item.title.localeCompare(right.item.title, "nb"); + }) + .slice(0, limit) + .map((entry) => entry.item); +} + +function scoreMenuSearchItem(item: MenuSearchItem, normalizedQuery: string, queryTokens: string[]) { + const title = item.titleText; + const search = item.searchText; + let score = item.priority ?? 0; + + if (title === normalizedQuery) { + score += 1_200; + } else if (title.startsWith(normalizedQuery)) { + score += 900; + } else if (search.startsWith(normalizedQuery)) { + score += 700; + } else if (title.includes(normalizedQuery)) { + score += 520; + } else if (search.includes(normalizedQuery)) { + score += 320; + } + + let matchedTokens = 0; + + for (const token of queryTokens) { + if (title.includes(token)) { + matchedTokens += 1; + score += title.startsWith(token) ? 160 : 110; + continue; + } + + if (search.includes(token)) { + matchedTokens += 1; + score += 55; + continue; + } + + return -1; + } + + if (matchedTokens === 0) { + return -1; + } + + if (queryTokens.length > 1 && matchedTokens === queryTokens.length) { + score += 140; + } + + if (item.kind === "facility") { + score += 24; + } + + return score; +} diff --git a/init.sql b/init.sql index 1767583..673de97 100644 --- a/init.sql +++ b/init.sql @@ -31,12 +31,15 @@ CREATE TABLE courses ( facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, holes INTEGER NOT NULL, + physical_hole_count INTEGER, + include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE, par INTEGER, length_meters INTEGER, course_type VARCHAR(100), architect VARCHAR(255), course_guide_url VARCHAR(255), status VARCHAR(50) DEFAULT 'Ukjent', + is_visible BOOLEAN NOT NULL DEFAULT TRUE, status_updated_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/migrations/2026-05-05_add_course_physical_hole_count.sql b/migrations/2026-05-05_add_course_physical_hole_count.sql new file mode 100644 index 0000000..f12b621 --- /dev/null +++ b/migrations/2026-05-05_add_course_physical_hole_count.sql @@ -0,0 +1,10 @@ +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS physical_hole_count INTEGER; + +UPDATE courses c +SET physical_hole_count = NULLIF(REGEXP_REPLACE(COALESCE(f.amenities->>'antall_hull', ''), '[^0-9]+', '', 'g'), '')::INTEGER +FROM facilities f +WHERE c.facility_id = f.id + AND c.is_main_course = TRUE + AND c.physical_hole_count IS NULL + AND COALESCE(f.amenities->>'antall_hull', '') <> ''; diff --git a/migrations/2026-05-06_add_course_include_in_physical_hole_total.sql b/migrations/2026-05-06_add_course_include_in_physical_hole_total.sql new file mode 100644 index 0000000..0693aa6 --- /dev/null +++ b/migrations/2026-05-06_add_course_include_in_physical_hole_total.sql @@ -0,0 +1,6 @@ +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE courses +SET include_in_physical_hole_total = TRUE +WHERE include_in_physical_hole_total IS NULL; diff --git a/migrations/2026-05-06_repair_physical_hole_count_from_amenities.sql b/migrations/2026-05-06_repair_physical_hole_count_from_amenities.sql new file mode 100644 index 0000000..b21db82 --- /dev/null +++ b/migrations/2026-05-06_repair_physical_hole_count_from_amenities.sql @@ -0,0 +1,18 @@ +WITH parsed_hole_counts AS ( + SELECT + c.id AS course_id, + NULLIF(SUBSTRING(COALESCE(f.amenities->>'antall_hull', '') FROM '([0-9]+)'), '')::INTEGER AS parsed_hole_count + FROM courses c + JOIN facilities f ON f.id = c.facility_id + WHERE c.is_main_course = TRUE + AND COALESCE(f.amenities->>'antall_hull', '') <> '' +) +UPDATE courses c +SET physical_hole_count = p.parsed_hole_count +FROM parsed_hole_counts p +WHERE c.id = p.course_id + AND p.parsed_hole_count IS NOT NULL + AND ( + c.physical_hole_count IS NULL + OR c.physical_hole_count > (p.parsed_hole_count * 2) + ); diff --git a/schema.sql b/schema.sql index 195b379..be78eef 100644 --- a/schema.sql +++ b/schema.sql @@ -47,12 +47,15 @@ CREATE TABLE courses ( facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, holes INTEGER, + physical_hole_count INTEGER, + include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE, par INTEGER, length_meters INTEGER, course_type VARCHAR(255), architect VARCHAR(255), status VARCHAR(255), is_main_course BOOLEAN DEFAULT TRUE, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );