From 20e694cda56339d7dd437391a1b20c06d333bf95 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 10 May 2026 08:04:51 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20endring=20i=20skraping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg | Bin 0 -> 43049 bytes backend/import_wp.py | 2 +- backend/main.py | 218 ++++++++-- docker-compose.prod.yml | 1 + frontend/src/app/FacilitySearch.tsx | 59 ++- .../rediger/[slug]/EditFacilityClient.tsx | 63 ++- .../app/api/admin/revalidate-public/route.ts | 92 ++++ frontend/src/app/api/search/menu/route.ts | 122 ++++++ frontend/src/app/facilityData.ts | 136 +++++- .../app/golfbaner/[slug]/CourseDisplay.tsx | 66 ++- .../golfbaner/[slug]/FacilityDetailView.tsx | 56 ++- frontend/src/app/golfbaner/page.tsx | 3 +- .../app/internal/revalidate-public/route.ts | 92 ++++ frontend/src/app/pageSeo.ts | 7 + frontend/src/app/publicFacilities.ts | 43 +- frontend/src/app/sitePages.ts | 7 + frontend/src/components/Header.tsx | 19 +- frontend/src/components/HeaderSearch.tsx | 408 ++++++++++++++++++ frontend/src/lib/menuSearch.ts | 135 ++++++ init.sql | 3 + ...6-05-05_add_course_physical_hole_count.sql | 10 + ..._course_include_in_physical_hole_total.sql | 6 + ...air_physical_hole_count_from_amenities.sql | 18 + schema.sql | 3 + 24 files changed, 1466 insertions(+), 103 deletions(-) create mode 100644 2026-05-05 19.13.57 teeoff.no 745d5e8e5e2a.jpg create mode 100644 frontend/src/app/api/admin/revalidate-public/route.ts create mode 100644 frontend/src/app/api/search/menu/route.ts create mode 100644 frontend/src/app/internal/revalidate-public/route.ts create mode 100644 frontend/src/components/HeaderSearch.tsx create mode 100644 frontend/src/lib/menuSearch.ts create mode 100644 migrations/2026-05-05_add_course_physical_hole_count.sql create mode 100644 migrations/2026-05-06_add_course_include_in_physical_hole_total.sql create mode 100644 migrations/2026-05-06_repair_physical_hole_count_from_amenities.sql 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 0000000000000000000000000000000000000000..8e9ce21f2bcf84a0f8707a01a7388b2f04de6571 GIT binary patch literal 43049 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 M;QuP({bk_)0Q*DI;s5{u literal 0 HcmV?d00001 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 );