Før endring i skraping

This commit is contained in:
Erol Haagenrud 2026-05-10 08:04:51 +02:00
parent 1ff8ee2c26
commit 20e694cda5
24 changed files with 1466 additions and 103 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View file

@ -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') 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') 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 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 curr_len = 0
for h_num in range(1, 19): for h_num in range(1, 19):
p = parse_int(acf.get(f'hull_{h_num}_par{suffix}')) p = parse_int(acf.get(f'hull_{h_num}_par{suffix}'))

View file

@ -90,6 +90,11 @@ FACILITY_RATING_NOTIFICATION_TO_EMAIL = os.getenv(
INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip() INDEXNOW_KEY = os.getenv("INDEXNOW_KEY", "").strip()
INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip() INDEXNOW_KEY_LOCATION = os.getenv("INDEXNOW_KEY_LOCATION", "").strip()
INDEXNOW_ENDPOINT = os.getenv("INDEXNOW_ENDPOINT", "https://api.indexnow.org/indexnow").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 = { PUBLIC_FACILITIES_CACHE_TTLS = {
"search": 900, "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" 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: async def trigger_frontend_public_revalidation(
facilities_cache = getattr(app.state, "public_facilities_cache", None) *,
if isinstance(facilities_cache, dict): include_facilities: bool,
facilities_cache.clear() 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) try:
if isinstance(detail_cache, dict): async with httpx.AsyncClient(timeout=5.0) as client:
detail_cache.clear() 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: if include_place_pages:
place_page_cache = getattr(app.state, "public_place_page_cache", None) 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): if isinstance(site_page_cache, dict):
site_page_cache.clear() 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: def get_configured_public_base_url() -> str:
for env_name in ("PUBLIC_BASE_URL", "NEXT_PUBLIC_SITE_URL"): 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', 'weather_url', 'lat', 'lng', 'golfamore', 'golfamore_url', 'nsg_url', 'has_golfpakker',
'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description', 'vtg_pris', 'vtg_lenke', 'vtg_beskrivelse', 'camper_parking', 'meta_title', 'meta_description',
'footnote', 'footnote_updated_at', 'footnote', 'footnote_updated_at',
'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', 'status_updated_at', 'amenities', 'golfamore_data', 'nsg_data', 'vtg_datoer', 'total_hole_count',
'course_statuses', 'weather_forecast', 'main_physical_hole_count', 'total_physical_hole_count', 'course_statuses', 'weather_forecast',
} }
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | { FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
'has_golfpakker', 'has_golfpakker',
@ -1819,6 +1868,10 @@ async def ensure_public_query_indexes(conn) -> None:
CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx
ON courses (facility_id, is_main_course DESC, id ASC) 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(""" await conn.execute("""
CREATE INDEX IF NOT EXISTS holes_course_id_idx CREATE INDEX IF NOT EXISTS holes_course_id_idx
ON holes (course_id) 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: 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(""" await conn.execute("""
CREATE TABLE IF NOT EXISTS tees ( CREATE TABLE IF NOT EXISTS tees (
id SERIAL PRIMARY KEY, 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 CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx
ON hole_lengths (hole_id, tee_id) 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") course_columns = await get_table_columns(conn, "courses")
hole_columns = await get_table_columns(conn, "holes") hole_columns = await get_table_columns(conn, "holes")
@ -2494,6 +2564,7 @@ async def build_facility_course_payloads(
SELECT * SELECT *
FROM courses FROM courses
WHERE facility_id = $1 WHERE facility_id = $1
AND COALESCE(is_visible, TRUE) = TRUE
AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent')))
ORDER BY is_main_course DESC, id ASC 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: for course in submitted_courses:
normalized_course = dict(course) normalized_course = dict(course)
normalized_course['is_main_course'] = bool(course.get('is_main_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) normalized_courses.append(normalized_course)
if normalized_courses: 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] holes = [hole for hole in (course.get('holes') or []) if hole]
tees = [tee for tee in (course.get('tees') or []) if tee] tees = [tee for tee in (course.get('tees') or []) if tee]
hole_count = len(holes) or None 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')) course_par = parse_optional_int(course.get('par'))
submitted_course_length_meters = parse_optional_int(course.get('length_meters')) 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 valid_until = None
if course_id: if course_id:
await conn.execute(""" if "is_visible" in course_columns:
UPDATE courses await conn.execute("""
SET name=$1, holes=$2, par=$3, length_meters=$4, architect=$5, UPDATE courses
status=$6, is_main_course=$7, slope_valid_until=$8 SET name=$1, holes=$2, physical_hole_count=$3, par=$4, length_meters=$5, architect=$6,
WHERE id=$9 AND facility_id=$10 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, course_par, submitted_course_length_meters, """,
course.get('architect'), course.get('status'), course.get('is_main_course'), course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
valid_until, course_id, facility_id) 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: else:
course_id = await conn.fetchval(""" if "is_visible" in course_columns:
INSERT INTO courses ( course_id = await conn.fetchval("""
facility_id, name, holes, par, length_meters, architect, INSERT INTO courses (
status, is_main_course, slope_valid_until 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) )
RETURNING id VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
""", 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'), facility_id, course.get('name'), hole_count, physical_hole_count, course_par, submitted_course_length_meters,
valid_until) 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)) 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 * SELECT *
FROM facilities FROM facilities
WHERE is_published IS DISTINCT FROM FALSE 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 = """ course_statuses_cte = """
@ -4452,6 +4558,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
FROM courses c FROM courses c
JOIN published_facilities pf ON pf.id = c.facility_id JOIN published_facilities pf ON pf.id = c.facility_id
WHERE c.status != 'finnes_ingen_bane_to' WHERE c.status != 'finnes_ingen_bane_to'
AND COALESCE(c.is_visible, TRUE) = TRUE
GROUP BY c.facility_id 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 FROM courses c
JOIN holes h ON h.course_id = c.id JOIN holes h ON h.course_id = c.id
JOIN published_facilities pf ON pf.id = c.facility_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 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 courses c ON c.id = t.course_id AND c.id = h.course_id
JOIN published_facilities pf ON pf.id = c.facility_id JOIN published_facilities pf ON pf.id = c.facility_id
WHERE hl.length_meters BETWEEN 30 AND 900 WHERE hl.length_meters BETWEEN 30 AND 900
AND COALESCE(c.is_visible, TRUE) = TRUE
GROUP BY c.facility_id GROUP BY c.facility_id
) )
""" """
@ -4543,7 +4675,10 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
WITH WITH
{published_facilities_cte}, {published_facilities_cte},
{course_statuses_cte}, {course_statuses_cte},
{weather_compact_cte} {weather_compact_cte},
{main_course_meta_cte},
{physical_hole_totals_cte},
{hole_counts_cte}
SELECT SELECT
pf.id, pf.id,
pf.slug, pf.slug,
@ -4579,9 +4714,15 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
pf.footnote_updated_at, pf.footnote_updated_at,
pf.status_updated_at, pf.status_updated_at,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, 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 COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM published_facilities pf FROM published_facilities pf
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id 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 LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
ORDER BY pf.name ASC 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}, {published_facilities_cte},
{course_statuses_cte}, {course_statuses_cte},
{weather_compact_cte}, {weather_compact_cte},
{main_course_meta_cte},
{physical_hole_totals_cte},
{hole_counts_cte}, {hole_counts_cte},
{hole_lengths_cte} {hole_lengths_cte}
SELECT SELECT
@ -4635,6 +4778,8 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
pf.status_updated_at, pf.status_updated_at,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
COALESCE(hc.total_hole_count, 0) AS total_hole_count, 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, 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.shortest_hole_meters,
hl.longest_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 course_statuses cs ON cs.facility_id = pf.id
LEFT JOIN weather_compact wc ON wc.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 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 hole_lengths hl ON hl.facility_id = pf.id
ORDER BY pf.name ASC 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}, {published_facilities_cte},
{course_statuses_cte}, {course_statuses_cte},
{weather_full_cte}, {weather_full_cte},
{main_course_meta_cte},
{physical_hole_totals_cte},
{hole_counts_cte}, {hole_counts_cte},
{hole_lengths_cte} {hole_lengths_cte}
SELECT SELECT
pf.*, pf.*,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
COALESCE(hc.total_hole_count, 0) AS total_hole_count, 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, 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.shortest_hole_meters,
hl.longest_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 FROM published_facilities pf
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
LEFT JOIN hole_counts hc ON hc.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 hole_lengths hl ON hl.facility_id = pf.id
LEFT JOIN weather_full wf ON wf.facility_id = pf.id LEFT JOIN weather_full wf ON wf.facility_id = pf.id
ORDER BY pf.name ASC 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 {} config = SITE_PAGE_CONFIGS.get(normalized_key) or {}
path = str(config.get("path") or "").strip() 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: if path:
schedule_indexnow_submission( schedule_indexnow_submission(
collect_page_indexnow_urls([path]), 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), 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( schedule_indexnow_submission(
collect_page_indexnow_urls([f"/sted/{normalized_slug}"]), collect_page_indexnow_urls([f"/sted/{normalized_slug}"]),
reason="admin place page upsert", reason="admin place page upsert",
@ -5271,6 +5424,7 @@ async def update_admin_site_page_seo(page_key: str, request: SitePageSeoUpsertRe
"meninger": "/meninger", "meninger": "/meninger",
"simulatorer": "/simulatorer", "simulatorer": "/simulatorer",
} }
invalidate_public_api_caches(include_facilities=False, include_site_pages=True)
schedule_indexnow_submission( schedule_indexnow_submission(
collect_page_indexnow_urls([page_path_map[normalized_key]]), collect_page_indexnow_urls([page_path_map[normalized_key]]),
reason="admin site page seo upsert", reason="admin site page seo upsert",

View file

@ -65,6 +65,7 @@ services:
command: npm start command: npm start
environment: environment:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
PUBLIC_SESSION_SECRET: ${PUBLIC_SESSION_SECRET}
volumes: volumes:
- ./frontend/public:/app/public - ./frontend/public:/app/public
depends_on: depends_on:

View file

@ -5,7 +5,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type CSSProperties } from "react"; 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 SortMethod = "updated" | "dist" | "alpha";
type Variant = "home" | "catalog"; type Variant = "home" | "catalog";
@ -42,6 +42,7 @@ type Facility = {
footnote?: string | null; footnote?: string | null;
footnote_updated_at?: string | null; footnote_updated_at?: string | null;
status_updated_at?: string | null; status_updated_at?: string | null;
total_hole_count?: number | null;
amenities?: unknown; amenities?: unknown;
golfamore_data?: unknown; golfamore_data?: unknown;
nsg_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 matchesHoleFilter = (holeValue: string, filterValue: string) => {
const normalizedHole = normalizeText(holeValue);
if (!filterValue) return true; if (!filterValue) return true;
if (filterValue === "18-plus") return normalizedHole.includes("18"); return normalizeText(holeValue) === normalizeText(filterValue);
if (filterValue === "18") return normalizedHole === "18"; };
if (filterValue === "9") return normalizedHole === "9" || normalizedHole === "9 9";
if (filterValue === "6-12") return normalizedHole === "6" || normalizedHole === "12"; const getHoleFilterLabel = (value: string) => {
if (filterValue === "under-utvikling") return normalizedHole.includes("utvikling"); const trimmedValue = String(value || "").trim();
return true; if (!trimmedValue) return "";
if (/^\d+$/.test(trimmedValue)) return `${trimmedValue} hull`;
return trimmedValue;
}; };
const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => {
@ -638,7 +640,7 @@ export default function FacilitySearch({
const countySlug = slugify(facility.county || ""); const countySlug = slugify(facility.county || "");
const regions = getFacilityRegions(facility.county || ""); const regions = getFacilityRegions(facility.county || "");
const holeValue = String(amenities.antall_hull || "").trim(); const holeValue = getFacilityVisibleHoleValue(facility);
const primaryStatus = getPrimaryStatus(statuses); const primaryStatus = getPrimaryStatus(statuses);
const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status)); const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status));
const hasGolfamore = const hasGolfamore =
@ -771,6 +773,35 @@ export default function FacilitySearch({
weatherDayFilter, weatherDayFilter,
]); ]);
const holeFilterOptions = useMemo(() => {
const seen = new Set<string>();
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 = [ const filtersCount = [
areaFilter, areaFilter,
statusFilter, statusFilter,
@ -878,11 +909,11 @@ export default function FacilitySearch({
<FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}> <FieldSelect label="Antall hull" value={holeFilter} onChange={setHoleFilter} labelClassName={labelClassName}>
<option value="">Alle antall hull</option> <option value="">Alle antall hull</option>
<option value="18-plus">18 hull eller mer</option> {holeFilterOptions.map((option) => (
<option value="18">Nøyaktig 18 hull</option> <option key={option.value} value={option.value}>
<option value="9">9 hull</option> {option.label}
<option value="6-12">6 eller 12 hull</option> </option>
<option value="under-utvikling">Under utvikling</option> ))}
</FieldSelect> </FieldSelect>
<FieldSelect <FieldSelect

View file

@ -607,7 +607,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
...initialData, ...initialData,
is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false, is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
courses: Array.isArray(initialData?.courses) ? initialData.courses : [], courses: Array.isArray(initialData?.courses)
? initialData.courses.map((course: any) => ({
...course,
is_visible: course?.is_visible !== false,
}))
: [],
videos: normalizeFacilityVideos(initialData?.videos), videos: normalizeFacilityVideos(initialData?.videos),
}); });
const [activeTab, setActiveTab] = useState('generelt'); 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)}`, _clientId: `course-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: `Ny bane ${existingCourses.length + 1}`, name: `Ny bane ${existingCourses.length + 1}`,
status: 'ukjent', status: 'ukjent',
physical_hole_count: '',
include_in_physical_hole_total: true,
par: '', par: '',
length_meters: '', length_meters: '',
architect: '', architect: '',
is_main_course: existingCourses.length === 0, is_main_course: existingCourses.length === 0,
is_visible: true,
slope_valid_until: '', slope_valid_until: '',
tees: [], tees: [],
holes: Array.from({ length: 18 }, (_, index) => ({ holes: Array.from({ length: 18 }, (_, index) => ({
@ -1466,8 +1474,8 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<AccordionSection <AccordionSection
key={course.id || course._clientId || cIdx} key={course.id || course._clientId || cIdx}
title={course.name || `Bane ${cIdx + 1}`} title={course.name || `Bane ${cIdx + 1}`}
subtitle={course.is_main_course ? 'Hovedbane' : 'Sekundærbane'} subtitle={course.is_visible === false ? 'Skjult bane' : course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
badge={`${course.holes?.length || 0} hull`} badge={`${Number(course.physical_hole_count) > 0 ? Number(course.physical_hole_count) : (course.holes?.length || 0)} hull`}
> >
<div className="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b-2 border-gray-200 pb-4"> <div className="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b-2 border-gray-200 pb-4">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@ -1481,8 +1489,23 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
/> />
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Hovedbane</span> <span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Hovedbane</span>
</label> </label>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}> <label className="inline-flex items-center gap-3 rounded-xl bg-white px-4 py-2 shadow-sm">
{course.is_main_course ? 'Hovedbane' : 'Sekundærbane'} <input
type="checkbox"
checked={course.is_visible !== false}
onChange={e => {
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = { ...course, is_visible: e.target.checked };
return nextCourses;
});
}}
className="h-4 w-4 accent-[#8bc34a]"
/>
<span className="text-xs font-black uppercase tracking-widest text-[#11280f]">Vis offentlig</span>
</label>
<span className={`px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest ${course.is_visible === false ? 'bg-[#3d4b40] text-white shadow-md' : course.is_main_course ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-300 text-gray-700'}`}>
{course.is_visible === false ? 'Skjult' : course.is_main_course ? 'Hovedbane' : 'Sekundærbane'}
</span> </span>
</div> </div>
<button onClick={() => handleRemoveCourse(cIdx)} className="btn btn-md btn-danger w-full md:w-auto">Slett bane</button> <button onClick={() => handleRemoveCourse(cIdx)} className="btn btn-md btn-danger w-full md:w-auto">Slett bane</button>
@ -1526,6 +1549,36 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
}); });
}} /> }} />
</div> </div>
<div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Fysiske Hull</label>
<input type="number" min="1" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.physical_hole_count ?? ""} onChange={e => {
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, physical_hole_count: e.target.value === "" ? "" : Number(e.target.value)};
return nextCourses;
});
}} />
</div>
<div className="flex flex-col gap-2 mb-6 md:col-span-2">
<label className="inline-flex items-start gap-3 rounded-2xl border-2 border-gray-300 bg-white p-4 shadow-sm">
<input
type="checkbox"
checked={course.include_in_physical_hole_total !== false}
onChange={e => {
updateCourses((courses) => {
const nextCourses = [...courses];
nextCourses[cIdx] = {...course, include_in_physical_hole_total: e.target.checked};
return nextCourses;
});
}}
className="mt-1 h-4 w-4 accent-[#8bc34a]"
/>
<span className="flex flex-col gap-1">
<span className="text-xs font-black uppercase tracking-widest text-gray-600">Tell med i anleggets totale hulltall</span>
<span className="text-sm font-medium text-gray-700">Skru av for baner som ellers vil gi dobbeltelling i anleggets totale hulltall.</span>
</span>
</label>
</div>
<div className="flex flex-col gap-2 mb-6"> <div className="flex flex-col gap-2 mb-6">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label> <label className="text-xs font-black uppercase tracking-widest text-gray-600">Utløpsdato Slope</label>
<input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => { <input type="date" className="p-4 rounded-2xl border-2 border-gray-300 focus:border-[#8bc34a] outline-none font-bold text-black bg-white text-base shadow-sm" value={course.slope_valid_until ? course.slope_valid_until.split('T')[0] : ""} onChange={e => {

View file

@ -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,
});
}

View file

@ -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<FacilityRecord>("search", REVALIDATE_SECONDS, {
allowEmpty: true,
}),
getCourseVisits(),
getOpinionArticles(),
]);
const items = new Map<string, MenuSearchItem>();
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;
}

View file

@ -3,6 +3,7 @@ import { STATUS_MAP } from "@/config/constants";
export type CourseStatus = { export type CourseStatus = {
status?: string; status?: string;
name?: string; name?: string;
is_visible?: boolean;
}; };
export type FacilityRecord = { export type FacilityRecord = {
@ -36,6 +37,8 @@ export type FacilityRecord = {
greenfee?: unknown; greenfee?: unknown;
standard_medlemskap?: number | null; standard_medlemskap?: number | null;
total_hole_count?: number | null; total_hole_count?: number | null;
main_physical_hole_count?: number | null;
total_physical_hole_count?: number | null;
hole_par_counts?: unknown; hole_par_counts?: unknown;
shortest_hole_meters?: number | null; shortest_hole_meters?: number | null;
longest_hole_meters?: number | null; longest_hole_meters?: number | null;
@ -259,6 +262,58 @@ export const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
return "ukjent"; return "ukjent";
}; };
export const getFacilityVisibleHoleCount = (
facility: Pick<FacilityRecord, "total_physical_hole_count" | "total_hole_count" | "amenities">
) => {
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<Record<string, unknown>>(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<FacilityRecord, "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count" | "amenities">
) => {
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<Record<string, unknown>>(facility.amenities, {});
return String(amenities.antall_hull || "").trim();
};
export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status"; export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status";
export const formatUpdatedDate = (value: string | null | undefined) => { export const formatUpdatedDate = (value: string | null | undefined) => {
@ -304,7 +359,7 @@ export const enrichFacilities = (
Array.isArray(rawStatuses) && rawStatuses.length > 0 Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses ? rawStatuses
: [{ status: "ukjent", name: "" }]; : [{ status: "ukjent", name: "" }];
const holeValue = String(amenities.antall_hull || "").trim(); const holeValue = getFacilityVisibleHoleValue(facility);
const countySlug = slugify(facility.county || ""); const countySlug = slugify(facility.county || "");
const regions = getFacilityRegions(facility.county || ""); const regions = getFacilityRegions(facility.county || "");
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
@ -528,6 +583,61 @@ const getHoleCategory = (value: unknown) => {
return null; return null;
}; };
const getFacilityPlaceHoleCategory = (
facility: Pick<FacilityRecord, "amenities" | "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count">
) => {
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<Record<string, unknown>>(facility.amenities, {});
const amenityCategory = getHoleCategory(amenities.antall_hull);
if (amenityCategory) {
return amenityCategory;
}
return getHoleCategory(getFacilityVisibleHoleValue(facility));
};
const getFacilityPlaceHoleCount = (
facility: Pick<FacilityRecord, "amenities" | "main_physical_hole_count" | "total_physical_hole_count" | "total_hole_count">
) => {
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 getPrimetimeGreenfee = (facility: FacilityRecord) => {
const rows = parseJson<Array<Record<string, unknown>>>(facility.greenfee, []); const rows = parseJson<Array<Record<string, unknown>>>(facility.greenfee, []);
if (!Array.isArray(rows) || rows.length === 0) return null; 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 bestPrice = Math.max(...selectionPool.map((candidate) => candidate.price));
const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice); const bestPriceCandidates = selectionPool.filter((candidate) => candidate.price === bestPrice);
const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest); const bestExplicitGuestCandidates = bestPriceCandidates.filter((candidate) => candidate.isExplicitGuest);
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {}); const totalHoleCount = getFacilityVisibleHoleCount(facility);
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 finalPriceCandidate = const finalPriceCandidate =
bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0]; bestExplicitGuestCandidates.length > 0 ? bestExplicitGuestCandidates[0] : bestPriceCandidates[0];
const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true; const selectedIsPartialRound = finalPriceCandidate?.isPartialRound === true;
@ -727,14 +825,14 @@ export const buildPlaceStats = (facilities: EnrichedFacility[]): PlaceStats => {
return { return {
facilityCount: relevantFacilities.length, 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, totalCourseCount: courseTotals.total,
openCourseCount: courseTotals.open, openCourseCount: courseTotals.open,
openNowCount: relevantFacilities.filter((facility) => OPEN_NOW_STATUSES.has(normalizeStatus(facility.primaryStatus))).length, openNowCount: relevantFacilities.filter((facility) => OPEN_NOW_STATUSES.has(normalizeStatus(facility.primaryStatus))).length,
hole18Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "18").length, hole18Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "18").length,
hole9Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "9").length, hole9Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "9").length,
hole6Count: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "6").length, hole6Count: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "6").length,
hole27PlusCount: relevantFacilities.filter((facility) => getHoleCategory(parseJson<Record<string, unknown>>(facility.amenities, {}).antall_hull) === "27+").length, hole27PlusCount: relevantFacilities.filter((facility) => getFacilityPlaceHoleCategory(facility) === "27+").length,
par3HoleCount: holeParTotals.par3, par3HoleCount: holeParTotals.par3,
par4HoleCount: holeParTotals.par4, par4HoleCount: holeParTotals.par4,
par5HoleCount: holeParTotals.par5, par5HoleCount: holeParTotals.par5,

View file

@ -34,6 +34,19 @@ const getHoleLength = (hole: any, teeKey: string) => {
return typeof value === "number" || typeof value === "string" ? value : null; 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 }) { export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) {
const [hcp, setHcp] = useState("15.0"); const [hcp, setHcp] = useState("15.0");
const [gender, setGender] = useState<Gender>('herrer'); const [gender, setGender] = useState<Gender>('herrer');
@ -78,30 +91,51 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null; const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null;
const activeTee = selectedColumn?.tee || null; const activeTee = selectedColumn?.tee || null;
const selectedTeeLabel = selectedColumn?.label || "Valgt utslag"; 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<number>(
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<number, number>(
strokeIndexOrder.map((value: number, index: number) => [value, index + 1] as const)
);
const strokesPerCycle = strokeIndexOrder.length || allHoles.length || 18;
let playingHandicap = 0; let playingHandicap = 0;
if (activeTee && hcp) { if (activeTee && hcp) {
const exactHcp = Number(hcp.replace(',', '.')); const exactHcp = toNumber(hcp);
const slope = Number( const slope = Number(
gender === 'damer' gender === 'damer'
? activeTee.slope_women || activeTee.slope_men || 113 ? activeTee.slope_women || activeTee.slope_men || 113
: activeTee.slope_men || activeTee.slope_women || 113 : activeTee.slope_men || activeTee.slope_women || 113
); );
const courseRating = Number( const courseRating = toNumber(
String( gender === 'damer'
gender === 'damer' ? activeTee.cr_women || activeTee.cr_men || coursePar
? activeTee.cr_women || activeTee.cr_men || course.par : activeTee.cr_men || activeTee.cr_women || coursePar
: activeTee.cr_men || activeTee.cr_women || course.par
).replace(',', '.')
); );
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) => { const getExtraStrokes = (hcpIndex: number) => {
if (!hcpIndex || isNaN(playingHandicap)) return 0; if (!Number.isFinite(playingHandicap)) return 0;
const base = Math.floor(playingHandicap / 18);
const rem = playingHandicap % 18; const normalizedHcpIndex = Number(hcpIndex);
return base + (hcpIndex <= rem ? 1 : 0); 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); const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0);
@ -132,7 +166,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-white p-3 shadow-sm"> <div className="rounded-2xl bg-white p-3 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Mottatt</p> <p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Mottatt</p>
<p className="mt-1 text-lg font-black text-[#11280f]">{extra > 0 ? `+${extra}` : '-'}</p> <p className="mt-1 text-lg font-black text-[#11280f]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</p>
</div> </div>
<div className="rounded-2xl bg-[#eef5e4] p-3 shadow-sm"> <div className="rounded-2xl bg-[#eef5e4] p-3 shadow-sm">
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Din Par</p> <p className="text-[10px] font-black uppercase tracking-[0.16em] text-[#7ca982]">Din Par</p>
@ -171,7 +205,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
<h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2> <h2 className="break-words text-4xl font-black tracking-tighter text-[#11280f] sm:text-5xl">{courseDisplayName}</h2>
) : null} ) : null}
<p className="mt-2 mb-1 text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]"> <p className="mt-2 mb-1 text-xs font-black uppercase tracking-[0.2em] text-[#7ca982]">
Par {course.par} {course.length_meters || '--'} meter Par {coursePar} {course.length_meters || '--'} meter
</p> </p>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400"> <p className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Rating utløper: {slopeExpiry} Rating utløper: {slopeExpiry}
@ -250,7 +284,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td> <td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td> <td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td> <td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td> <td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</td>
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td> <td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
{activeColumns.map((column) => ( {activeColumns.map((column) => (
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}> <td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>
@ -277,7 +311,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour
<td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td> <td className="p-4 pl-10 text-left text-lg font-black text-gray-800">{hole.hole_number}</td>
<td className="border-l border-gray-100 bg-white p-4">{hole.par}</td> <td className="border-l border-gray-100 bg-white p-4">{hole.par}</td>
<td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td> <td className="border-l border-gray-100 p-4 text-xs font-mono text-gray-300">{hole.hcp_index}</td>
<td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : '-'}</td> <td className="border-l border-gray-100 bg-[#7ca982]/5 p-4 font-mono text-[#7ca982]">{extra > 0 ? `+${extra}` : extra < 0 ? `${extra}` : '-'}</td>
<td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td> <td className="border-l border-gray-100 bg-[#7ca982]/10 p-4 text-lg font-mono">{hole.par + extra}</td>
{activeColumns.map((column) => ( {activeColumns.map((column) => (
<td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}> <td key={column.teeKey} className={`border-l border-white p-4 font-mono transition-all ${column.theme.col} ${column.theme.text}`}>

View file

@ -341,8 +341,56 @@ export default function FacilityDetailView({
return parseSharedJson(val, fallback); 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 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 amenities = parseJson(facility.amenities, {});
const camperParking = String(facility.camper_parking || "").trim(); const camperParking = String(facility.camper_parking || "").trim();
const galleryRaw = parseJson(facility.gallery, []); const galleryRaw = parseJson(facility.gallery, []);
@ -417,7 +465,7 @@ export default function FacilityDetailView({
hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null, hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null,
{ id: 'prices', label: 'Priser', showOnMobile: true }, { id: 'prices', label: 'Priser', showOnMobile: true },
hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null, 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( ].filter(
(item): item is { id: string; label: string; showOnMobile: boolean } => Boolean(item) (item): item is { id: string; label: string; showOnMobile: boolean } => Boolean(item)
); );
@ -725,7 +773,7 @@ export default function FacilityDetailView({
<div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col"> <div className="bg-white p-10 md:rounded-[3rem] shadow-sm text-sm font-bold text-gray-700 flex flex-col">
<h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3> <h3 className="text-lg font-black mb-8 uppercase tracking-tighter text-[#11280f]">Banen</h3>
<div className="space-y-5 flex-grow"> <div className="space-y-5 flex-grow">
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{amenities.antall_hull || '--'}</span></div> <div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Hull:</span><span>{facilityPhysicalHoleCount || amenityHoleDisplay || amenities.antall_hull || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div> <div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Lengde:</span><span>{facility.length_meters ? `${facility.length_meters}m` : '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div> <div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Sesong:</span><span>{facility.season || '--'}</span></div>
<div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div> <div className="flex justify-between border-b border-gray-50 pb-3"><span className="text-gray-400">Byggeår:</span><span>{facility.established_year || '--'}</span></div>
@ -1162,6 +1210,7 @@ export default function FacilityDetailView({
/> />
{/* 9. SCOREKORT SEKSJON */} {/* 9. SCOREKORT SEKSJON */}
{activeCourses.length > 0 && (
<section id="scorecards" className="pt-10 space-y-20 overflow-hidden"> <section id="scorecards" className="pt-10 space-y-20 overflow-hidden">
<h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3> <h3 className="text-center text-3xl md:text-5xl font-black uppercase tracking-tighter">Scorekort</h3>
<div className="w-full flex flex-col items-center gap-20"> <div className="w-full flex flex-col items-center gap-20">
@ -1172,6 +1221,7 @@ export default function FacilityDetailView({
))} ))}
</div> </div>
</section> </section>
)}
<FacilityFeedbackForm <FacilityFeedbackForm
facilityId={Number(facility.id)} facilityId={Number(facility.id)}

View file

@ -9,7 +9,6 @@ import {
createPageMetadata, createPageMetadata,
} from "@/app/seo"; } from "@/app/seo";
export const revalidate = 900;
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no"; const fallbackPageTitle = "Alle norske golfbaner: Finn din neste runde på gress | TeeOff.no";
@ -33,7 +32,7 @@ export async function generateMetadata() {
export default async function GolfCoursesIndexPage() { export default async function GolfCoursesIndexPage() {
const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription); const seo = await resolveSitePageSeo("golfbaner", fallbackPageTitle, fallbackPageDescription);
const safeData = await fetchPublicFacilities<FacilityRecord>("search", revalidate); const safeData = await fetchPublicFacilities<FacilityRecord>("search", 0);
const collectionJsonLd = createCollectionPageJsonLd({ const collectionJsonLd = createCollectionPageJsonLd({
name: seo.title, name: seo.title,
description: seo.description, description: seo.description,

View file

@ -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,
});
}

View file

@ -2,6 +2,13 @@ import { cache } from "react";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { resolveSeoDescription, resolveSeoTitle } from "@/app/seo"; 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 = { export type SitePageSeoRecord = {
page_key?: string; page_key?: string;
meta_title?: string | null; meta_title?: string | null;

View file

@ -1,17 +1,19 @@
import { unstable_cache } from "next/cache";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fetchPublicFacilities<T>( 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<T>(
view: string, view: string,
_revalidateSeconds: number, allowEmpty: boolean,
{ attempts: number,
allowEmpty = false,
attempts = 3,
}: {
allowEmpty?: boolean;
attempts?: number;
} = {},
): Promise<T[]> { ): Promise<T[]> {
let lastError: Error | null = null; let lastError: Error | null = null;
@ -45,3 +47,26 @@ export async function fetchPublicFacilities<T>(
throw lastError ?? new Error("Kunne ikke hente anlegg"); throw lastError ?? new Error("Kunne ikke hente anlegg");
} }
export async function fetchPublicFacilities<T>(
view: string,
_revalidateSeconds: number,
{
allowEmpty = false,
attempts = 3,
}: {
allowEmpty?: boolean;
attempts?: number;
} = {},
): Promise<T[]> {
const normalizedView = String(view || "").trim().toLowerCase() || "default";
const readFromCache = unstable_cache(
() => fetchPublicFacilitiesUncached<T>(view, allowEmpty, attempts),
[`public-facilities:${normalizedView}`, allowEmpty ? "allow-empty" : "require-data"],
{
tags: getPublicFacilitiesCacheTags(view),
},
);
return readFromCache();
}

View file

@ -1,6 +1,13 @@
import { cache } from "react"; import { cache } from "react";
import { API_URL } from "@/config/constants"; 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 = { export type SitePageRecord = {
page_key: string; page_key: string;
eyebrow?: string | null; eyebrow?: string | null;

View file

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import HeaderSearch from "@/components/HeaderSearch";
type NavItem = { type NavItem = {
href: string; href: string;
@ -134,7 +135,7 @@ export default function Header() {
/> />
</Link> </Link>
<nav className="hidden items-center gap-8 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 md:flex"> <nav className="hidden items-center gap-6 text-[12px] font-extrabold uppercase tracking-[0.14em] text-white/90 lg:flex xl:gap-8">
<div <div
className="group relative" className="group relative"
onMouseEnter={openPlacesMenu} onMouseEnter={openPlacesMenu}
@ -247,9 +248,16 @@ export default function Header() {
</div> </div>
)} )}
</div> </div>
<HeaderSearch
onOpen={() => {
setIsPlacesOpen(false);
setIsResourcesOpen(false);
}}
/>
</nav> </nav>
<button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white md:hidden" aria-label="Meny"> <button onClick={() => setIsOpen(!isOpen)} className="p-2 text-white lg:hidden" aria-label="Meny">
<div className="mb-1.5 h-0.5 w-6 bg-current transition-all"></div> <div className="mb-1.5 h-0.5 w-6 bg-current transition-all"></div>
<div className="mb-1.5 h-0.5 w-6 bg-current"></div> <div className="mb-1.5 h-0.5 w-6 bg-current"></div>
<div className="h-0.5 w-6 bg-current"></div> <div className="h-0.5 w-6 bg-current"></div>
@ -315,6 +323,13 @@ export default function Header() {
))} ))}
</div> </div>
</div> </div>
<div className="border-t border-white/10 pt-5">
<p className={mobileMenuSectionHeadingClass}>Søk</p>
<div className="mt-4">
<HeaderSearch mobile onNavigate={closeAllMenus} />
</div>
</div>
</div> </div>
</div> </div>
)} )}

View file

@ -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<MenuSearchItem[]> | null = null;
async function fetchJsonArray<T>(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<SearchFacilityRecord>("/api/facilities?view=search"),
fetchJsonArray<SearchArticleRecord>("/api/articles?section=banebesok"),
fetchJsonArray<SearchArticleRecord>("/api/articles?section=meninger"),
])
.then((results) => {
const items = new Map<string, MenuSearchItem>();
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 (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
d="M10.5 4.75a5.75 5.75 0 1 0 0 11.5a5.75 5.75 0 0 0 0-11.5Zm0 1.5a4.25 4.25 0 1 1 0 8.5a4.25 4.25 0 0 1 0-8.5Zm6.43 9.62l3.07 3.07a.75.75 0 1 1-1.06 1.06l-3.07-3.07a.75.75 0 1 1 1.06-1.06Z"
fill="currentColor"
/>
</svg>
);
}
export default function HeaderSearch({ mobile = false, onNavigate, onOpen }: HeaderSearchProps) {
const router = useRouter();
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const [items, setItems] = useState<MenuSearchItem[] | null>(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<HTMLFormElement>) => {
event.preventDefault();
await openOrSubmit();
};
const handleKeyDown = async (event: KeyboardEvent<HTMLInputElement>) => {
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 (
<div
ref={containerRef}
className={wrapperClassName}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget as Node | null;
if (!containerRef.current?.contains(nextTarget)) {
setIsFocused(false);
}
}}
>
<div className={shellClassName}>
<form className={formClassName} onSubmit={handleSubmit}>
<div
className={`flex items-center ${mobile ? "gap-3 px-4 py-3.5" : `px-3 py-2.5 ${isExpanded ? "gap-3" : "justify-center"}`}`}
>
<button
type="button"
onClick={() => {
void openOrSubmit();
}}
className="flex h-5 w-5 shrink-0 items-center justify-center text-white/78 transition hover:text-[#8BC34A]"
aria-label="Søk"
>
<SearchIcon />
</button>
<input
ref={inputRef}
type="search"
value={query}
onChange={(event) => 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}
/>
</div>
</form>
{showDropdown ? (
<div className={`absolute ${mobile ? "left-0 right-0" : "right-0 w-full"} top-[calc(100%+0.7rem)] z-[2200]`}>
<div className="overflow-hidden rounded-[1.5rem] border border-white/10 bg-[#25312A] shadow-2xl">
{loading ? (
<div className="px-4 py-4 text-sm font-bold text-white/72">Laster søk </div>
) : error ? (
<div className="px-4 py-4 text-sm font-bold text-[#FFD7CC]">{error}</div>
) : results.length > 0 ? (
<ul className="py-2">
{results.map((result, index) => (
<li key={result.href}>
<button
type="button"
onMouseDown={(event) => {
event.preventDefault();
navigateTo(result.href);
}}
className={`flex w-full items-start justify-between gap-4 px-4 py-3 text-left transition ${
index === activeIndex ? "bg-white/10" : "hover:bg-white/6"
}`}
>
<span className="min-w-0">
<span className="block truncate text-sm font-black tracking-normal text-white">
{result.title}
</span>
{result.subtitle ? (
<span className="block truncate text-xs font-bold tracking-normal text-white/62">
{result.subtitle}
</span>
) : null}
</span>
<span className="shrink-0 rounded-full border border-white/10 bg-white/8 px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.18em] text-[#8BC34A]">
{result.label}
</span>
</button>
</li>
))}
</ul>
) : query.trim() ? (
<div className="px-4 py-4 text-sm font-bold text-white/72">Ingen treff søket ditt.</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
);
}

View file

@ -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;
}

View file

@ -31,12 +31,15 @@ CREATE TABLE courses (
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE, facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
holes INTEGER NOT NULL, holes INTEGER NOT NULL,
physical_hole_count INTEGER,
include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE,
par INTEGER, par INTEGER,
length_meters INTEGER, length_meters INTEGER,
course_type VARCHAR(100), course_type VARCHAR(100),
architect VARCHAR(255), architect VARCHAR(255),
course_guide_url VARCHAR(255), course_guide_url VARCHAR(255),
status VARCHAR(50) DEFAULT 'Ukjent', status VARCHAR(50) DEFAULT 'Ukjent',
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
status_updated_at TIMESTAMP, status_updated_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP

View file

@ -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', '') <> '';

View file

@ -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;

View file

@ -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)
);

View file

@ -47,12 +47,15 @@ CREATE TABLE courses (
facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE, facility_id INTEGER REFERENCES facilities(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
holes INTEGER, holes INTEGER,
physical_hole_count INTEGER,
include_in_physical_hole_total BOOLEAN NOT NULL DEFAULT TRUE,
par INTEGER, par INTEGER,
length_meters INTEGER, length_meters INTEGER,
course_type VARCHAR(255), course_type VARCHAR(255),
architect VARCHAR(255), architect VARCHAR(255),
status VARCHAR(255), status VARCHAR(255),
is_main_course BOOLEAN DEFAULT TRUE, is_main_course BOOLEAN DEFAULT TRUE,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );