From 8a8690ed9271cfae94e393fcf412420ebbb7f3cb Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 29 Apr 2026 09:52:38 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20galleri=20og=20videogalleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 652 ++++++++++++++++-- .../rediger/[slug]/EditFacilityClient.tsx | 431 ++++++------ .../app/golfbaner/[slug]/CourseDisplay.tsx | 326 ++++----- schema.sql | 3 + 4 files changed, 990 insertions(+), 422 deletions(-) diff --git a/backend/main.py b/backend/main.py index 42b197c..9d9e278 100644 --- a/backend/main.py +++ b/backend/main.py @@ -907,6 +907,7 @@ LEGACY_FACILITY_FIELD_ALIASES = { 'vtg_presentasjon': 'vtg_beskrivelse', 'vtg_kursdatoer': 'vtg_datoer', } +LEGACY_TEE_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest'] FACILITY_ALLOWED_FIELDS = [ 'name', 'description', 'established_year', 'season', 'banetype', 'architect', 'length_meters', @@ -1822,6 +1823,18 @@ async def ensure_public_query_indexes(conn) -> None: CREATE INDEX IF NOT EXISTS holes_course_id_idx ON holes (course_id) """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS tees_course_sort_idx + ON tees (course_id, sort_order ASC, id ASC) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS hole_lengths_hole_idx + ON hole_lengths (hole_id) + """) + await conn.execute(""" + CREATE INDEX IF NOT EXISTS hole_lengths_tee_idx + ON hole_lengths (tee_id) + """) await conn.execute(""" CREATE INDEX IF NOT EXISTS facility_weather_forecast_facility_day_idx ON facility_weather_forecast (facility_id, day_offset) @@ -1841,6 +1854,148 @@ async def get_table_columns(conn, table_name: str, schema_name: str = "public") return {str(row["column_name"]) for row in rows} +async def ensure_scorecard_tables(conn) -> None: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS tees ( + id SERIAL PRIMARY KEY, + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + cr_men NUMERIC(4, 1), + slope_men INTEGER, + cr_women NUMERIC(4, 1), + slope_women INTEGER + ) + """) + await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_men NUMERIC(4, 1)") + await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_men INTEGER") + await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS cr_women NUMERIC(4, 1)") + await conn.execute("ALTER TABLE tees ADD COLUMN IF NOT EXISTS slope_women INTEGER") + await conn.execute(""" + CREATE TABLE IF NOT EXISTS hole_lengths ( + id SERIAL PRIMARY KEY, + hole_id INTEGER NOT NULL REFERENCES holes(id) ON DELETE CASCADE, + tee_id INTEGER NOT NULL REFERENCES tees(id) ON DELETE CASCADE, + length_meters INTEGER + ) + """) + await conn.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS hole_lengths_hole_tee_uidx + ON hole_lengths (hole_id, tee_id) + """) + + course_columns = await get_table_columns(conn, "courses") + hole_columns = await get_table_columns(conn, "holes") + if "tee_boxes" not in course_columns or "lengths" not in hole_columns: + return + + legacy_courses = await conn.fetch( + """ + SELECT c.id, c.tee_boxes + FROM courses c + WHERE c.tee_boxes IS NOT NULL + ORDER BY c.id ASC + """ + ) + + for course_row in legacy_courses: + course_id = int(course_row["id"]) + tee_boxes = coerce_json_dict(course_row["tee_boxes"]) + holes = [ + { + "id": int(hole_row["id"]), + "lengths": coerce_json_dict(hole_row["lengths"]), + } + for hole_row in await conn.fetch( + "SELECT id, lengths FROM holes WHERE course_id = $1 ORDER BY hole_number ASC, id ASC", + course_id, + ) + ] + + legacy_keys = resolve_legacy_course_keys(tee_boxes, holes) + if not legacy_keys: + continue + + existing_tee_rows = await conn.fetch( + """ + SELECT id, name, cr_men, slope_men, cr_women, slope_women + FROM tees + WHERE course_id = $1 + ORDER BY sort_order ASC, id ASC + """, + course_id, + ) + + compact_length = max(len(men_entries := coerce_json_list(tee_boxes.get("herrer"))), len(women_entries := coerce_json_list(tee_boxes.get("damer")))) + fallback_names = {key.replace("_", " ").title().lower() for key in LEGACY_TEE_KEYS} + has_suspicious_fallback_tees = any( + str(tee_row["name"] or "").strip().lower() in fallback_names + and tee_row["cr_men"] is None + and tee_row["slope_men"] is None + and tee_row["cr_women"] is None + and tee_row["slope_women"] is None + for tee_row in existing_tee_rows + ) + should_repair_existing_tees = ( + bool(existing_tee_rows) + and 0 < compact_length < len(LEGACY_TEE_KEYS) + and len(existing_tee_rows) != len(legacy_keys) + and has_suspicious_fallback_tees + ) + + if existing_tee_rows and not should_repair_existing_tees: + continue + + if should_repair_existing_tees: + await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id) + + created_tee_ids: dict[str, int] = {} + + for sort_order, key in enumerate(legacy_keys): + men_entry = extract_legacy_tee_entry(men_entries, key, legacy_keys) + women_entry = extract_legacy_tee_entry(women_entries, key, legacy_keys) + + tee_id = await conn.fetchval( + """ + INSERT INTO tees ( + course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + """, + course_id, + build_legacy_tee_name(men_entry, women_entry, key, sort_order), + sort_order, + parse_optional_float(men_entry.get("baneverdi")), + parse_optional_int(men_entry.get("slopeverdi")), + parse_optional_float(women_entry.get("baneverdi_damer")), + parse_optional_int(women_entry.get("slopeverdi_damer")), + ) + created_tee_ids[key] = int(tee_id) + + if not created_tee_ids: + continue + + for hole in holes: + length_map = coerce_json_dict(hole.get("lengths")) + for key, tee_id in created_tee_ids.items(): + length_meters = parse_optional_int(length_map.get(key)) + if length_meters is None: + continue + await conn.execute( + """ + INSERT INTO hole_lengths (hole_id, tee_id, length_meters) + VALUES ($1, $2, $3) + ON CONFLICT (hole_id, tee_id) + DO UPDATE SET length_meters = EXCLUDED.length_meters + """, + int(hole["id"]), + tee_id, + length_meters, + ) + + def generate_totp_qr_svg(provisioning_uri: str) -> str: image = qrcode.make( provisioning_uri, @@ -1964,6 +2119,175 @@ def parse_optional_int(value: Any) -> int | None: return None +def parse_optional_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, bool): + return float(int(value)) + if isinstance(value, (int, float)): + return float(value) + + trimmed = str(value).strip() + if not trimmed: + return None + + try: + return float(trimmed.replace(",", ".")) + except ValueError: + return None + + +def coerce_json_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + return parsed if isinstance(parsed, list) else [] + return [] + + +def coerce_json_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def build_submitted_tee_key(tee: dict[str, Any], fallback_index: int) -> str: + parsed_id = parse_optional_int(tee.get("id")) + if parsed_id is not None: + return str(parsed_id) + + for field_name in ("client_key", "_clientId", "temp_id"): + text = str(tee.get(field_name) or "").strip() + if text: + return text + + return f"new-tee-{fallback_index}" + + +def normalize_tee_name(value: Any, fallback_index: int) -> str: + normalized = str(value or "").strip() + return normalized or f"Utslag {fallback_index + 1}" + + +def resolve_compact_legacy_keys(active_keys: list[str], compact_length: int) -> list[str]: + if compact_length <= 0: + return [] + + if active_keys: + compact_active_keys = [key for key in active_keys if key in LEGACY_TEE_KEYS][:compact_length] + if len(compact_active_keys) == compact_length: + return compact_active_keys + + return LEGACY_TEE_KEYS[:compact_length] + + +def extract_legacy_tee_entry(entries: list[Any], key: str, active_keys: list[str]) -> dict[str, Any]: + normalized_entries = [entry if isinstance(entry, dict) else {} for entry in (entries or [])] + if not normalized_entries: + return {} + + compact_length = len(normalized_entries) + if 0 < compact_length < len(LEGACY_TEE_KEYS): + compact_keys = resolve_compact_legacy_keys(active_keys, compact_length) + compact_index = compact_keys.index(key) if key in compact_keys else -1 + if compact_index >= 0 and compact_index < compact_length: + return dict(normalized_entries[compact_index] or {}) + return {} + + key_index = LEGACY_TEE_KEYS.index(key) + if key_index < compact_length: + return dict(normalized_entries[key_index] or {}) + + return {} + + +def legacy_tee_entry_has_content(entry: dict[str, Any]) -> bool: + return any( + str(entry.get(field_name) or "").strip() + for field_name in ( + "navn_utslag", + "baneverdi", + "slopeverdi", + "navn_utslag_damer", + "baneverdi_damer", + "slopeverdi_damer", + ) + ) + + +def resolve_legacy_course_keys(tee_boxes: dict[str, Any], holes: list[dict[str, Any]]) -> list[str]: + active_keys = [ + key + for key in LEGACY_TEE_KEYS + if any(parse_optional_int(coerce_json_dict(hole.get("lengths")).get(key)) is not None for hole in holes) + ] + + men_entries = coerce_json_list(tee_boxes.get("herrer")) + women_entries = coerce_json_list(tee_boxes.get("damer")) + compact_length = max(len(men_entries), len(women_entries)) + compact_keys = ( + resolve_compact_legacy_keys(active_keys, compact_length) + if 0 < compact_length < len(LEGACY_TEE_KEYS) + else [] + ) + + if compact_keys: + candidate_keys = [key for key in compact_keys if key in active_keys] + keys_to_inspect = list(compact_keys) + else: + candidate_keys = list(active_keys) if active_keys else [] + keys_to_inspect = list(LEGACY_TEE_KEYS) + + for key in keys_to_inspect: + men_entry = extract_legacy_tee_entry(men_entries, key, active_keys) + women_entry = extract_legacy_tee_entry(women_entries, key, active_keys) + if legacy_tee_entry_has_content(men_entry) or legacy_tee_entry_has_content(women_entry): + if key not in candidate_keys: + candidate_keys.append(key) + + if candidate_keys: + return [key for key in LEGACY_TEE_KEYS if key in candidate_keys] + + if compact_keys: + return compact_keys + + return [] + + +def build_legacy_tee_name(men_entry: dict[str, Any], women_entry: dict[str, Any], key: str, fallback_index: int) -> str: + for candidate in ( + men_entry.get("navn_utslag"), + women_entry.get("navn_utslag_damer"), + women_entry.get("navn_utslag"), + men_entry.get("navn_utslag_damer"), + ): + normalized = str(candidate or "").strip() + if normalized: + return normalized + + return normalize_tee_name(key.replace("_", " ").title(), fallback_index) + + +def coerce_length_mapping(hole: dict[str, Any]) -> dict[str, Any]: + if isinstance(hole.get("lengths_by_tee"), dict): + return dict(hole.get("lengths_by_tee") or {}) + return coerce_json_dict(hole.get("lengths")) + + def sanitize_hero_images(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] @@ -2117,6 +2441,130 @@ def schedule_facility_indexnow_submission_for_fields( ) +def format_course_payload_row( + course_row: Any, + tees_by_course_id: dict[int, list[dict[str, Any]]], + holes_by_course_id: dict[int, list[dict[str, Any]]], + length_rows_by_hole_id: dict[int, list[dict[str, Any]]], +) -> dict[str, Any]: + data = dict(course_row) + course_id = int(data["id"]) + + if isinstance(data.get("slope_valid_until"), (date, datetime)): + data["slope_valid_until"] = data["slope_valid_until"].isoformat() + + data.pop("tee_boxes", None) + data["tees"] = tees_by_course_id.get(course_id, []) + + holes_payload: list[dict[str, Any]] = [] + for hole_row in holes_by_course_id.get(course_id, []): + hole_data = dict(hole_row) + hole_id = int(hole_data["id"]) + hole_data.pop("lengths", None) + hole_data["lengths_by_tee"] = { + str(length_row["tee_id"]): int(length_row["length_meters"]) + for length_row in length_rows_by_hole_id.get(hole_id, []) + if length_row.get("length_meters") is not None + } + holes_payload.append(hole_data) + + data["holes"] = holes_payload + return data + + +async def build_facility_course_payloads( + conn, + facility_id: int, + *, + include_unpublished_courses: bool, +) -> list[dict[str, Any]]: + if include_unpublished_courses: + course_rows = await conn.fetch( + """ + SELECT * + FROM courses + WHERE facility_id = $1 + ORDER BY is_main_course DESC, id ASC + """, + facility_id, + ) + else: + course_rows = await conn.fetch( + """ + SELECT * + FROM courses + WHERE facility_id = $1 + AND (is_main_course = TRUE OR (status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) + ORDER BY is_main_course DESC, id ASC + """, + facility_id, + ) + + if not course_rows: + return [] + + course_ids = [int(row["id"]) for row in course_rows] + tee_rows = await conn.fetch( + """ + SELECT * + FROM tees + WHERE course_id = ANY($1::int[]) + ORDER BY course_id ASC, sort_order ASC, id ASC + """, + course_ids, + ) + hole_rows = await conn.fetch( + """ + SELECT * + FROM holes + WHERE course_id = ANY($1::int[]) + ORDER BY course_id ASC, hole_number ASC, id ASC + """, + course_ids, + ) + + hole_ids = [int(row["id"]) for row in hole_rows] + length_rows = await conn.fetch( + """ + SELECT hl.hole_id, hl.tee_id, hl.length_meters + FROM hole_lengths hl + JOIN tees t ON t.id = hl.tee_id + WHERE hl.hole_id = ANY($1::int[]) + AND t.course_id = ANY($2::int[]) + ORDER BY hl.hole_id ASC, t.sort_order ASC, hl.tee_id ASC + """, + hole_ids or [0], + course_ids, + ) + + tees_by_course_id: dict[int, list[dict[str, Any]]] = {} + for tee_row in tee_rows: + tee_data = dict(tee_row) + tee_id = int(tee_data["id"]) + course_id = int(tee_data["course_id"]) + if tee_data.get("cr_men") is not None: + tee_data["cr_men"] = float(tee_data["cr_men"]) + if tee_data.get("cr_women") is not None: + tee_data["cr_women"] = float(tee_data["cr_women"]) + tee_data["client_key"] = str(tee_id) + tees_by_course_id.setdefault(course_id, []).append(tee_data) + + holes_by_course_id: dict[int, list[dict[str, Any]]] = {} + for hole_row in hole_rows: + course_id = int(hole_row["course_id"]) + holes_by_course_id.setdefault(course_id, []).append(dict(hole_row)) + + length_rows_by_hole_id: dict[int, list[dict[str, Any]]] = {} + for length_row in length_rows: + hole_id = int(length_row["hole_id"]) + length_rows_by_hole_id.setdefault(hole_id, []).append(dict(length_row)) + + return [ + format_course_payload_row(course_row, tees_by_course_id, holes_by_course_id, length_rows_by_hole_id) + for course_row in course_rows + ] + + async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tuple[str, set[str]]: normalized_data = apply_legacy_facility_field_aliases(data) update_data = {k: v for k, v in normalized_data.items() if k in FACILITY_ALLOWED_FIELDS} @@ -2129,6 +2577,8 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") facility_columns = await get_table_columns(conn, "facilities") + course_columns = await get_table_columns(conn, "courses") + hole_columns = await get_table_columns(conn, "holes") update_data = {k: v for k, v in update_data.items() if k in facility_columns} if update_data: @@ -2216,11 +2666,12 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu retained_course_ids: list[int] = [] for course in normalized_courses: - course_id = course.get('id') + course_id = parse_optional_int(course.get('id')) 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 course_par = parse_optional_int(course.get('par')) - course_length_meters = parse_optional_int(course.get('length_meters')) + submitted_course_length_meters = parse_optional_int(course.get('length_meters')) valid_until_str = course.get('slope_valid_until') if valid_until_str == "" or valid_until_str is None: @@ -2232,60 +2683,140 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu except ValueError: valid_until = None - tee_boxes_json = json.dumps(course.get('tee_boxes') or {}) - 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, tee_boxes=$8::jsonb, - slope_valid_until=$9 - WHERE id=$10 AND facility_id=$11 + status=$6, is_main_course=$7, slope_valid_until=$8 + WHERE id=$9 AND facility_id=$10 """, - course.get('name'), hole_count, course_par, course_length_meters, + course.get('name'), hole_count, course_par, submitted_course_length_meters, course.get('architect'), course.get('status'), course.get('is_main_course'), - tee_boxes_json, valid_until, course_id, facility_id) + valid_until, 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, tee_boxes, slope_valid_until + status, is_main_course, slope_valid_until ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id """, - facility_id, course.get('name'), hole_count, course_par, course_length_meters, + facility_id, course.get('name'), hole_count, course_par, submitted_course_length_meters, course.get('architect'), course.get('status'), course.get('is_main_course'), - tee_boxes_json, valid_until) + valid_until) retained_course_ids.append(int(course_id)) + retained_tee_ids: list[int] = [] + submitted_tee_key_to_db_id: dict[str, int] = {} + + for tee_index, tee in enumerate(tees): + tee_data = dict(tee) + tee_id = parse_optional_int(tee_data.get("id")) + submitted_key = build_submitted_tee_key(tee_data, tee_index) + tee_name = normalize_tee_name(tee_data.get("name"), tee_index) + tee_sort_order = parse_optional_int(tee_data.get("sort_order")) + if tee_sort_order is None: + tee_sort_order = tee_index + + if tee_id: + await conn.execute( + """ + UPDATE tees + SET name = $1, + sort_order = $2, + cr_men = $3, + slope_men = $4, + cr_women = $5, + slope_women = $6 + WHERE id = $7 AND course_id = $8 + """, + tee_name, + tee_sort_order, + parse_optional_float(tee_data.get("cr_men")), + parse_optional_int(tee_data.get("slope_men")), + parse_optional_float(tee_data.get("cr_women")), + parse_optional_int(tee_data.get("slope_women")), + tee_id, + course_id, + ) + persisted_tee_id = tee_id + else: + persisted_tee_id = await conn.fetchval( + """ + INSERT INTO tees ( + course_id, name, sort_order, cr_men, slope_men, cr_women, slope_women + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + """, + course_id, + tee_name, + tee_sort_order, + parse_optional_float(tee_data.get("cr_men")), + parse_optional_int(tee_data.get("slope_men")), + parse_optional_float(tee_data.get("cr_women")), + parse_optional_int(tee_data.get("slope_women")), + ) + + retained_tee_ids.append(int(persisted_tee_id)) + submitted_tee_key_to_db_id[submitted_key] = int(persisted_tee_id) + submitted_tee_key_to_db_id[str(int(persisted_tee_id))] = int(persisted_tee_id) + retained_hole_ids: list[int] = [] + course_length_totals: dict[int, int] = {} for hole in holes: - hole_id = hole.get('id') + hole_id = parse_optional_int(hole.get('id')) hole_number = parse_optional_int(hole.get('hole_number')) hole_par = parse_optional_int(hole.get('par')) hole_hcp_index = parse_optional_int(hole.get('hcp_index')) - lengths_json = json.dumps(hole.get('lengths') or {}) if hole_id: await conn.execute(""" UPDATE holes - SET hole_number=$1, par=$2, hcp_index=$3, lengths=$4::jsonb - WHERE id=$5 AND course_id=$6 + SET hole_number=$1, par=$2, hcp_index=$3 + WHERE id=$4 AND course_id=$5 """, - hole_number, hole_par, hole_hcp_index, - lengths_json, hole_id, course_id) + hole_number, hole_par, hole_hcp_index, hole_id, course_id) else: hole_id = await conn.fetchval(""" - INSERT INTO holes (course_id, hole_number, par, hcp_index, lengths) - VALUES ($1, $2, $3, $4, $5::jsonb) + INSERT INTO holes (course_id, hole_number, par, hcp_index) + VALUES ($1, $2, $3, $4) RETURNING id """, - course_id, hole_number, hole_par, hole_hcp_index, - lengths_json) + course_id, hole_number, hole_par, hole_hcp_index) retained_hole_ids.append(int(hole_id)) + await conn.execute("DELETE FROM hole_lengths WHERE hole_id = $1", hole_id) + for submitted_tee_key, raw_length in coerce_length_mapping(dict(hole)).items(): + normalized_submitted_key = str(submitted_tee_key or "").strip() + if not normalized_submitted_key: + continue + + tee_id = submitted_tee_key_to_db_id.get(normalized_submitted_key) + if tee_id is None: + parsed_submitted_tee_id = parse_optional_int(normalized_submitted_key) + if parsed_submitted_tee_id is not None and parsed_submitted_tee_id in retained_tee_ids: + tee_id = parsed_submitted_tee_id + if tee_id is None: + continue + + length_meters = parse_optional_int(raw_length) + if length_meters is None: + continue + + await conn.execute( + """ + INSERT INTO hole_lengths (hole_id, tee_id, length_meters) + VALUES ($1, $2, $3) + """, + hole_id, + tee_id, + length_meters, + ) + course_length_totals[tee_id] = course_length_totals.get(tee_id, 0) + length_meters + if retained_hole_ids: await conn.execute( "DELETE FROM holes WHERE course_id = $1 AND NOT (id = ANY($2::int[]))", @@ -2295,6 +2826,31 @@ async def save_facility_full(conn, facility_id: int, data: dict[str, Any]) -> tu else: await conn.execute("DELETE FROM holes WHERE course_id = $1", course_id) + if retained_tee_ids: + await conn.execute( + "DELETE FROM tees WHERE course_id = $1 AND NOT (id = ANY($2::int[]))", + course_id, + retained_tee_ids, + ) + else: + await conn.execute("DELETE FROM tees WHERE course_id = $1", course_id) + + if submitted_course_length_meters is None and course_length_totals: + await conn.execute( + "UPDATE courses SET length_meters = $1 WHERE id = $2", + max(course_length_totals.values()), + course_id, + ) + + if "tee_boxes" in course_columns: + await conn.execute("UPDATE courses SET tee_boxes = NULL WHERE id = $1", course_id) + + if "lengths" in hole_columns and retained_hole_ids: + await conn.execute( + "UPDATE holes SET lengths = NULL WHERE id = ANY($1::int[])", + retained_hole_ids, + ) + if retained_course_ids: await conn.execute( "DELETE FROM courses WHERE facility_id = $1 AND NOT (id = ANY($2::int[]))", @@ -3141,6 +3697,7 @@ async def lifespan(app: FastAPI): ) async with app.state.pool.acquire() as conn: await ensure_facility_columns(conn) + await ensure_scorecard_tables(conn) await ensure_vtg_course_tables(conn) await ensure_place_pages_table(conn) await ensure_site_page_seo_table(conn) @@ -3960,15 +4517,14 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non hole_lengths AS ( SELECT c.facility_id, - MIN((length_value.value)::int) AS shortest_hole_meters, - MAX((length_value.value)::int) AS longest_hole_meters - FROM courses c - JOIN holes h ON h.course_id = c.id + MIN(hl.length_meters) AS shortest_hole_meters, + MAX(hl.length_meters) AS longest_hole_meters + FROM hole_lengths hl + JOIN holes h ON h.id = hl.hole_id + JOIN tees t ON t.id = hl.tee_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 - CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value) - WHERE length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst') - AND length_value.value ~ '^[0-9]+$' - AND (length_value.value)::int BETWEEN 30 AND 900 + WHERE hl.length_meters BETWEEN 30 AND 900 GROUP BY c.facility_id ) """ @@ -4249,17 +4805,6 @@ async def get_facility(slug: str, response: Response): async with app.state.pool.acquire() as conn: row = await conn.fetchrow(""" SELECT f.*, ( - SELECT jsonb_agg(c_data) FROM ( - SELECT c.*, ( - SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC) - FROM (SELECT * FROM holes WHERE course_id = c.id) h_data - ) as holes - FROM courses c - WHERE c.facility_id = f.id - AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) - ORDER BY c.is_main_course DESC, c.id ASC - ) c_data - ) as courses, ( SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( SELECT forecast_date, @@ -4288,6 +4833,11 @@ async def get_facility(slug: str, response: Response): raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") payload = sanitize_public_facility_row(row) + payload["courses"] = await build_facility_course_payloads( + conn, + int(row["id"]), + include_unpublished_courses=False, + ) write_public_cache_entry( detail_cache, normalized_slug, @@ -4439,16 +4989,6 @@ async def get_admin_facility(slug: str): async with app.state.pool.acquire() as conn: row = await conn.fetchrow(""" SELECT f.*, ( - SELECT jsonb_agg(c_data) FROM ( - SELECT c.*, ( - SELECT jsonb_agg(h_data ORDER BY h_data.hole_number ASC) - FROM (SELECT * FROM holes WHERE course_id = c.id) h_data - ) as holes - FROM courses c - WHERE c.facility_id = f.id - ORDER BY c.is_main_course DESC, c.id ASC - ) c_data - ) as courses, ( SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( SELECT forecast_date, @@ -4475,7 +5015,13 @@ async def get_admin_facility(slug: str): if not row: raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") - return format_row(row) + payload = format_row(row) + payload["courses"] = await build_facility_course_payloads( + conn, + int(row["id"]), + include_unpublished_courses=True, + ) + return payload @app.post("/api/admin/facilities") diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index c5c0895..3f37f5c 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -1,5 +1,5 @@ "use client"; -import { useRef, useState, type ChangeEvent, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ChangeEvent, type ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { adminFetch } from "@/config/adminFetch"; @@ -220,249 +220,292 @@ const ScrollToTopButton = () => { ); }; -// KOMPONENT 4: DEN NYE SCOREKORT-BYGGEREN +const createTeeClientKey = () => `tee-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const normalizeCourseTees = (rawTees: any[]) => { + const tees = Array.isArray(rawTees) ? rawTees : []; + return tees.map((tee: any, index: number) => ({ + ...tee, + client_key: String(tee?.client_key || tee?.id || createTeeClientKey()), + name: tee?.name || "", + sort_order: typeof tee?.sort_order === "number" ? tee.sort_order : index, + cr_men: tee?.cr_men ?? "", + slope_men: tee?.slope_men ?? "", + cr_women: tee?.cr_women ?? "", + slope_women: tee?.slope_women ?? "", + })); +}; + +const normalizeCourseHoles = (rawHoles: any[]) => { + const holes = Array.isArray(rawHoles) ? rawHoles : []; + if (holes.length === 0) { + return Array.from({ length: 18 }, (_, i) => ({ + hole_number: i + 1, + par: "", + hcp_index: "", + lengths_by_tee: {}, + })); + } + return holes + .map((hole: any) => ({ + ...hole, + lengths_by_tee: + hole?.lengths_by_tee && typeof hole.lengths_by_tee === "object" + ? { ...hole.lengths_by_tee } + : {}, + })) + .sort((a: any, b: any) => a.hole_number - b.hole_number); +}; + +// KOMPONENT 4: SCOREKORT-BYGGER const ScorecardBuilder = ({ course, onChange }: { course: any, onChange: (c: any) => void }) => { - const ALL_KEYS = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']; + const [tees, setTees] = useState(() => normalizeCourseTees(course.tees)); + const [holes, setHoles] = useState(() => normalizeCourseHoles(course.holes)); - const [holes, setHoles] = useState(() => { - const h = course.holes || []; - if (h.length === 0) { - return Array.from({length: 18}, (_, i) => ({ hole_number: i+1, par: '', hcp_index: '', lengths: {} })); - } - return h.sort((a: any, b: any) => a.hole_number - b.hole_number); - }); + useEffect(() => { + setTees(normalizeCourseTees(course.tees)); + setHoles(normalizeCourseHoles(course.holes)); + }, [course.id, course.tees, course.holes]); - const [activeKeys, setActiveKeys] = useState(() => { - const keys = new Set(); - holes.forEach(h => { - if (h.lengths) Object.keys(h.lengths).forEach(k => keys.add(k)); + const syncToParent = (nextTees: any[], nextHoles: any[]) => { + const normalizedTees = nextTees.map((tee, index) => ({ + ...tee, + sort_order: index, + client_key: String(tee.client_key || tee.id || createTeeClientKey()), + })); + const validTeeKeys = new Set(normalizedTees.map((tee) => String(tee.client_key))); + const normalizedHoles = nextHoles.map((hole) => { + const nextLengths = Object.fromEntries( + Object.entries(hole.lengths_by_tee || {}).filter(([teeKey]) => validTeeKeys.has(String(teeKey))) + ); + return { + ...hole, + lengths_by_tee: nextLengths, + }; }); - return ALL_KEYS.filter(k => keys.has(k)); - }); - const [tees, setTees] = useState(() => { - const herrer = course.tee_boxes?.herrer || []; - const damer = course.tee_boxes?.damer || []; - const initialTees = { herrer: {} as any, damer: {} as any }; - const herrerAreCompact = herrer.length > 0 && herrer.length < ALL_KEYS.length; - const damerAreCompact = damer.length > 0 && damer.length < ALL_KEYS.length; - - ALL_KEYS.forEach((key, idx) => { - const activeIdx = activeKeys.indexOf(key); - initialTees.herrer[key] = (herrerAreCompact && activeIdx >= 0 ? herrer[activeIdx] : herrer[idx]) || { navn_utslag: '', baneverdi: '', slopeverdi: '' }; - initialTees.damer[key] = (damerAreCompact && activeIdx >= 0 ? damer[activeIdx] : damer[idx]) || { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; - }); - return initialTees; - }); - - const syncToParent = (newHoles: any[], newKeys: string[], newTees: any) => { - const updatedTeeBoxes = { - herrer: ALL_KEYS.map(k => newTees.herrer[k] || {}), - damer: ALL_KEYS.map(k => newTees.damer[k] || {}) - }; onChange({ ...course, - holes: newHoles, - tee_boxes: updatedTeeBoxes + tees: normalizedTees, + holes: normalizedHoles, }); }; - const toggleKey = (key: string) => { - const newKeys = activeKeys.includes(key) - ? activeKeys.filter(k => k !== key) - : ALL_KEYS.filter(k => activeKeys.includes(k) || k === key); - setActiveKeys(newKeys); - - const newTees = { ...tees }; - if (!newTees.herrer[key]) newTees.herrer[key] = { navn_utslag: '', baneverdi: '', slopeverdi: '' }; - if (!newTees.damer[key]) newTees.damer[key] = { navn_utslag_damer: '', baneverdi_damer: '', slopeverdi_damer: '' }; - setTees(newTees); - syncToParent(holes, newKeys, newTees); + const updateTees = (updater: (currentTees: any[]) => any[]) => { + setTees((currentTees) => { + const nextTees = updater(currentTees); + syncToParent(nextTees, holes); + return nextTees; + }); }; - const updateTee = (gender: 'herrer'|'damer', key: string, field: string, value: string) => { - const newTees = { ...tees }; - newTees[gender][key] = { ...newTees[gender][key], [field]: value }; - setTees(newTees); - syncToParent(holes, activeKeys, newTees); + const updateHoles = (updater: (currentHoles: any[]) => any[]) => { + setHoles((currentHoles) => { + const nextHoles = updater(currentHoles); + syncToParent(tees, nextHoles); + return nextHoles; + }); }; - const updateHole = (index: number, field: string, value: string, lengthKey: string | null = null) => { - const newHoles = [...holes]; - if (lengthKey) { - newHoles[index].lengths = { ...newHoles[index].lengths, [lengthKey]: value === '' ? '' : Number(value) }; - } else { - newHoles[index][field] = value === '' ? '' : Number(value); - } - setHoles(newHoles); - syncToParent(newHoles, activeKeys, tees); + const addTee = () => { + updateTees((currentTees) => [ + ...currentTees, + { + client_key: createTeeClientKey(), + name: "", + sort_order: currentTees.length, + cr_men: "", + slope_men: "", + cr_women: "", + slope_women: "", + }, + ]); + }; + + const removeTee = (teeKey: string) => { + const nextTees = tees.filter((tee) => String(tee.client_key) !== teeKey); + const nextHoles = holes.map((hole) => { + const nextLengths = { ...(hole.lengths_by_tee || {}) }; + delete nextLengths[teeKey]; + return { ...hole, lengths_by_tee: nextLengths }; + }); + setTees(nextTees); + setHoles(nextHoles); + syncToParent(nextTees, nextHoles); + }; + + const moveTee = (teeKey: string, direction: -1 | 1) => { + updateTees((currentTees) => { + const index = currentTees.findIndex((tee) => String(tee.client_key) === teeKey); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= currentTees.length) { + return currentTees; + } + const nextTees = [...currentTees]; + const [item] = nextTees.splice(index, 1); + nextTees.splice(nextIndex, 0, item); + return nextTees; + }); + }; + + const updateTeeField = (teeKey: string, field: string, value: string) => { + updateTees((currentTees) => + currentTees.map((tee) => + String(tee.client_key) === teeKey + ? { ...tee, [field]: value } + : tee + ) + ); + }; + + const updateHoleField = (index: number, field: string, value: string) => { + updateHoles((currentHoles) => + currentHoles.map((hole, holeIndex) => + holeIndex === index + ? { ...hole, [field]: value === "" ? "" : Number(value) } + : hole + ) + ); + }; + + const updateHoleLength = (index: number, teeKey: string, value: string) => { + updateHoles((currentHoles) => + currentHoles.map((hole, holeIndex) => + holeIndex === index + ? { + ...hole, + lengths_by_tee: { + ...(hole.lengths_by_tee || {}), + [teeKey]: value === "" ? "" : Number(value), + }, + } + : hole + ) + ); }; const addHole = () => { - const newHoles = [...holes, { hole_number: holes.length + 1, par: '', hcp_index: '', lengths: {} }]; - setHoles(newHoles); - syncToParent(newHoles, activeKeys, tees); + updateHoles((currentHoles) => [ + ...currentHoles, + { + hole_number: currentHoles.length + 1, + par: "", + hcp_index: "", + lengths_by_tee: {}, + }, + ]); }; const removeLastHole = () => { - const newHoles = holes.slice(0, -1); - setHoles(newHoles); - syncToParent(newHoles, activeKeys, tees); + updateHoles((currentHoles) => currentHoles.slice(0, -1)); }; return ( -
-
- Aktive Utslagskolonner: - {ALL_KEYS.map(k => ( - - ))} -
- -
-
-

Herrer

-
- {activeKeys.map(k => ( -
-

{k}

-
- updateTee('herrer', k, 'navn_utslag', e.target.value)} /> - updateTee('herrer', k, 'baneverdi', e.target.value)} /> - updateTee('herrer', k, 'slopeverdi', e.target.value)} /> +
+
+
+
+

Utslag

+

Hvert utslag har navn, rating og slope for både herrer og damer.

+
+ {tees.length} utslag +
+
+ {tees.map((tee, index) => { + const teeKey = String(tee.client_key); + return ( +
+
+
+

Utslag {index + 1}

+ updateTeeField(teeKey, "name", e.target.value)} + /> +
+
+ + + +
+
+
+
+

Herrer

+
+ updateTeeField(teeKey, "cr_men", e.target.value)} /> + updateTeeField(teeKey, "slope_men", e.target.value)} /> +
+
+
+

Damer

+
+ updateTeeField(teeKey, "cr_women", e.target.value)} /> + updateTeeField(teeKey, "slope_women", e.target.value)} /> +
+
- ))} -
-
- -
-

Damer

-
- {activeKeys.map(k => ( -
-

{k}

-
- updateTee('damer', k, 'navn_utslag_damer', e.target.value)} /> - updateTee('damer', k, 'baneverdi_damer', e.target.value)} /> - updateTee('damer', k, 'slopeverdi_damer', e.target.value)} /> -
-
- ))} -
-
-
+ ); + })} +
+
+ +
+

Hull for hull

-

Hvert hull er et eget kort med par, hcp og lengder per aktiv utslagskolonne.

+

Hvert hull lagrer par, hcp og lengde per utslag.

{holes.length} hull
- {holes.map((h, idx) => ( -
+ {holes.map((hole, holeIndex) => ( +
-

Hull {h.hole_number}

- {activeKeys.length} utslag +

Hull {hole.hole_number}

+ {tees.length} utslag
- updateHole(idx, 'par', e.target.value)} /> + updateHoleField(holeIndex, "par", e.target.value)} />
- updateHole(idx, 'hcp_index', e.target.value)} /> + updateHoleField(holeIndex, "hcp_index", e.target.value)} />
- {activeKeys.map(k => ( -
- - updateHole(idx, 'lengths', e.target.value, k)} /> -
- ))} + {tees.map((tee) => { + const teeKey = String(tee.client_key); + return ( +
+ + updateHoleLength(holeIndex, teeKey, e.target.value)} + /> +
+ ); + })}
))}
-
- - - - - - - {activeKeys.map(k => )} - - {/* Herrer */} - - - {activeKeys.map(k => ( - - ))} - - {/* Damer */} - - - {activeKeys.map(k => ( - - ))} - - - - {holes.map((h, idx) => ( - - - - - {activeKeys.map(k => ( - - ))} - - ))} - -
HullParHCP{k}
- Herrer (Navn / CR / Slope) - -
- updateTee('herrer', k, 'navn_utslag', e.target.value)} /> -
- updateTee('herrer', k, 'baneverdi', e.target.value)} /> - updateTee('herrer', k, 'slopeverdi', e.target.value)} /> -
-
-
- Damer (Navn / CR / Slope) - -
- updateTee('damer', k, 'navn_utslag_damer', e.target.value)} /> -
- updateTee('damer', k, 'baneverdi_damer', e.target.value)} /> - updateTee('damer', k, 'slopeverdi_damer', e.target.value)} /> -
-
-
{h.hole_number} updateHole(idx, 'par', e.target.value)} /> updateHole(idx, 'hcp_index', e.target.value)} /> - updateHole(idx, 'lengths', e.target.value, k)} /> -
-
-
- - + +
); @@ -574,12 +617,12 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini architect: '', is_main_course: existingCourses.length === 0, slope_valid_until: '', - tee_boxes: { herrer: [], damer: [] }, + tees: [], holes: Array.from({ length: 18 }, (_, index) => ({ hole_number: index + 1, par: '', hcp_index: '', - lengths: {}, + lengths_by_tee: {}, })), }; }; diff --git a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx index 9bdd873..fbb7100 100644 --- a/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx +++ b/frontend/src/app/golfbaner/[slug]/CourseDisplay.tsx @@ -1,10 +1,8 @@ "use client"; -import { useState } from 'react'; -import { STATUS_MAP } from "@/config/constants"; +import { useEffect, useState } from 'react'; type Gender = 'herrer' | 'damer'; -// Designerens definisjon av fargetemaer - Nå med kraftigere tints for kolonnene const getTeeTheme = (label: string) => { const name = label.toLowerCase(); if (name.includes("svart") || name.includes("black")) { @@ -25,67 +23,78 @@ const getTeeTheme = (label: string) => { if (name.includes("grønn") || name.includes("gronn") || name.includes("green")) { return { header: "bg-emerald-500 text-white", col: "bg-emerald-50", text: "text-emerald-900" }; } - - // DEFAULT: Nøytral grå for utslag med tall (f.eks "52", "45") return { header: "bg-gray-200 text-gray-700", col: "bg-gray-100/60", text: "text-gray-600" }; }; +const getTeeKey = (tee: any) => String(tee?.id || tee?.client_key || ""); +const getTeeLabel = (tee: any, index: number) => String(tee?.name || `Utslag ${index + 1}`).trim(); + +const getHoleLength = (hole: any, teeKey: string) => { + const value = hole?.lengths_by_tee?.[teeKey]; + return typeof value === "number" || typeof value === "string" ? value : null; +}; + export default function CourseDisplay({ course, courseDisplayName = "" }: { course: any; courseDisplayName?: string }) { const [hcp, setHcp] = useState("15.0"); const [gender, setGender] = useState('herrer'); - const [selectedTeeIndex, setSelectedTeeIndex] = useState(0); - const allHoles = course.holes || []; + const allHoles = Array.isArray(course.holes) ? course.holes : []; const holesOut = allHoles.filter((h: any) => h.hole_number <= 9); const holesIn = allHoles.filter((h: any) => h.hole_number > 9); const hasInHoles = holesIn.length > 0; - const lengthKeys = ['lengst', 'lang', 'mellomlang', 'mellomkort', 'kort', 'kortest']; - const activeLengthKeys = lengthKeys.filter((key) => allHoles.some((h: any) => h.lengths?.[key])); - const availableTees = course.tee_boxes?.[gender] || []; - const fallbackTees = course.tee_boxes?.[gender === 'damer' ? 'herrer' : 'damer'] || []; + const availableTees = (Array.isArray(course.tees) ? [...course.tees] : []) + .sort((a: any, b: any) => { + const orderA = typeof a?.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER; + const orderB = typeof b?.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return Number(a?.id || 0) - Number(b?.id || 0); + }) + .filter((tee: any) => { + const teeKey = getTeeKey(tee); + return teeKey && allHoles.some((hole: any) => getHoleLength(hole, teeKey) !== null); + }); - const getTeeForColumn = (tees: any[], teeIndex: number, activePosition: number) => { - if (!Array.isArray(tees) || tees.length === 0) { - return undefined; + const [selectedTeeKey, setSelectedTeeKey] = useState(() => getTeeKey(availableTees[0])); + + useEffect(() => { + if (!availableTees.length) { + setSelectedTeeKey(""); + return; } - // Older/broken saves may store only active tees in sequence instead of the full 6-slot layout. - if (tees.length < lengthKeys.length) { - return tees[activePosition]; + if (!availableTees.some((tee: any) => getTeeKey(tee) === selectedTeeKey)) { + setSelectedTeeKey(getTeeKey(availableTees[0])); } + }, [availableTees, selectedTeeKey]); - return tees[teeIndex]; - }; + const activeColumns = availableTees.map((tee: any, index: number) => ({ + tee, + teeKey: getTeeKey(tee), + label: getTeeLabel(tee, index), + theme: getTeeTheme(getTeeLabel(tee, index)), + })); - const getTeeLabel = (tee: any, fallbackTee: any, fallback: string) => { - const primaryLabel = gender === 'damer' ? tee?.navn_utslag_damer : tee?.navn_utslag; - const secondaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag : fallbackTee?.navn_utslag_damer; - const tertiaryLabel = gender === 'damer' ? tee?.navn_utslag : tee?.navn_utslag_damer; - const quaternaryLabel = gender === 'damer' ? fallbackTee?.navn_utslag_damer : fallbackTee?.navn_utslag; - return String(primaryLabel || secondaryLabel || tertiaryLabel || quaternaryLabel || fallback).trim(); - }; + const selectedColumn = activeColumns.find((column) => column.teeKey === selectedTeeKey) || activeColumns[0] || null; + const activeTee = selectedColumn?.tee || null; + const selectedTeeLabel = selectedColumn?.label || "Valgt utslag"; - const activeColumns = activeLengthKeys.map((key, activePosition) => { - const teeIndex = lengthKeys.indexOf(key); - const tee = getTeeForColumn(availableTees, teeIndex, activePosition); - const fallbackTee = getTeeForColumn(fallbackTees, teeIndex, activePosition); - const label = getTeeLabel(tee, fallbackTee, key[0].toUpperCase() + key.slice(1)); - return { key, teeIndex, activePosition, label, theme: getTeeTheme(label) }; - }); - - // Kalkulering av SpH - const selectedColumn = activeColumns.find((column) => column.teeIndex === selectedTeeIndex) || activeColumns[0] || null; - const activeTee = selectedColumn - ? getTeeForColumn(availableTees, selectedColumn.teeIndex, selectedColumn.activePosition) - : undefined; let playingHandicap = 0; - if (activeTee && hcp) { const exactHcp = Number(hcp.replace(',', '.')); - const slope = Number(activeTee.slopeverdi || activeTee.slopeverdi_damer || 113); - const cr = Number(String(activeTee.baneverdi || activeTee.baneverdi_damer || course.par).replace(',', '.')); - playingHandicap = Math.round((exactHcp * (slope / 113)) + (cr - course.par)); + 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(',', '.') + ); + playingHandicap = Math.round((exactHcp * (slope / 113)) + (courseRating - course.par)); } const getExtraStrokes = (hcpIndex: number) => { @@ -96,23 +105,16 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour }; const sumPar = (holes: any[]) => holes.reduce((acc, h) => acc + (h.par || 0), 0); - const sumLen = (holes: any[], key: string) => holes.reduce((acc, h) => acc + (h.lengths?.[key] || 0), 0); - const selectedTeeLabel = activeTee - ? getTeeLabel( - activeTee, - selectedColumn ? getTeeForColumn(fallbackTees, selectedColumn.teeIndex, selectedColumn.activePosition) : undefined, - selectedColumn?.label || 'Valgt utslag' - ) - : 'Valgt utslag'; - - // Formater utløpsdato - const slopeExpiry = course.slope_valid_until + const sumLen = (holes: any[], teeKey: string) => + holes.reduce((acc, h) => acc + (Number(getHoleLength(h, teeKey)) || 0), 0); + + const slopeExpiry = course.slope_valid_until ? new Date(course.slope_valid_until).toLocaleDateString('nb-NO', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Ukjent'; const renderMobileHoleCard = (hole: any) => { const extra = getExtraStrokes(hole.hcp_index); - const selectedLength = selectedColumn ? hole.lengths?.[selectedColumn.key] : null; + const selectedLength = selectedColumn ? getHoleLength(hole, selectedColumn.teeKey) : null; return (
@@ -155,7 +157,7 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour

{selectedTeeLabel}

-

{selectedColumn ? sumLen(holes, selectedColumn.key) : '--'}

+

{selectedColumn ? sumLen(holes, selectedColumn.teeKey) : '--'}

@@ -163,66 +165,46 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour return (
- - {/* HEADER / KALKULATOR */}
- {courseDisplayName ? ( -

{courseDisplayName}

- ) : null} -

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

-

- Rating utløper: {slopeExpiry} -

-
- + {courseDisplayName ? ( +

{courseDisplayName}

+ ) : null} +

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

+

+ Rating utløper: {slopeExpiry} +

+
+
-
Kjønn - -
-
Utslag - -
-
Ditt HCP - setHcp(e.target.value)} className="w-full min-w-0 border-b-2 border-[#7ca982]/30 bg-transparent px-1 text-left font-black text-[#11280f] outline-none md:w-16 md:text-center" /> -
-
-

SpH

-

{playingHandicap || 0}

-
+
+ Kjønn + +
+
+ Utslag + +
+
+ Ditt HCP + setHcp(e.target.value)} className="w-full min-w-0 border-b-2 border-[#7ca982]/30 bg-transparent px-1 text-left font-black text-[#11280f] outline-none md:w-16 md:text-center" /> +
+
+

SpH

+

{playingHandicap || 0}

+
- {/* MOBIL SCOREKORT */}

Mobilscorekort

@@ -246,92 +228,86 @@ export default function CourseDisplay({ course, courseDisplayName = "" }: { cour {renderMobileSummaryCard("Totalt", allHoles)}
- {/* SCOREKORT TABELL */}
- +
- - - - - - - {activeColumns.map((col, i) => ( - + + + + + + + {activeColumns.map((column) => ( + ))} - {/* UT-RUNDE */} - {holesOut.map((h: any) => { - const extra = getExtraStrokes(h.hcp_index); + {holesOut.map((hole: any) => { + const extra = getExtraStrokes(hole.hcp_index); return ( - - - - - - - {activeColumns.map((col, i) => ( - + + + + + + + {activeColumns.map((column) => ( + ))} ); })} - - {/* UT RAD */} - - - - - {activeColumns.map((col, i) => ( - - ))} + + + + + + {activeColumns.map((column) => ( + + ))} - {/* INN-RUNDE */} - {hasInHoles && holesIn.map((h: any) => { - const extra = getExtraStrokes(h.hcp_index); + {hasInHoles && holesIn.map((hole: any) => { + const extra = getExtraStrokes(hole.hcp_index); return ( - - - - - - - {activeColumns.map((col, i) => ( - + + + + + + + {activeColumns.map((column) => ( + ))} ); })} - {/* INN RAD */} {hasInHoles && ( - - - - - {activeColumns.map((col, i) => ( - - ))} - + + + + + {activeColumns.map((column) => ( + + ))} + )} - - {/* TOTAL RAD */} - - - - - {activeColumns.map((col, i) => ( - - ))} + + + + + + {activeColumns.map((column) => ( + + ))}
HullParHCPMottattDin Par{col.label}
HullParHCPMottattDin Par{column.label}
{h.hole_number}{h.par}{h.hcp_index}{extra > 0 ? `+${extra}` : '-'}{h.par + extra} - {h.lengths?.[col.key] || '--'} -
{hole.hole_number}{hole.par}{hole.hcp_index}{extra > 0 ? `+${extra}` : '-'}{hole.par + extra} + {getHoleLength(hole, column.teeKey) || '--'} +
Ut{sumPar(holesOut)}{sumLen(holesOut, col.key)}
Ut{sumPar(holesOut)}{sumLen(holesOut, column.teeKey)}
{h.hole_number}{h.par}{h.hcp_index}{extra > 0 ? `+${extra}` : '-'}{h.par + extra} - {h.lengths?.[col.key] || '--'} -
{hole.hole_number}{hole.par}{hole.hcp_index}{extra > 0 ? `+${extra}` : '-'}{hole.par + extra} + {getHoleLength(hole, column.teeKey) || '--'} +
Inn{sumPar(holesIn)}{sumLen(holesIn, col.key)}
Inn{sumPar(holesIn)}{sumLen(holesIn, column.teeKey)}
Totalt{sumPar(allHoles)} - {sumLen(allHoles, col.key)} -
Totalt{sumPar(allHoles)} + {sumLen(allHoles, column.teeKey)} +
diff --git a/schema.sql b/schema.sql index c7e29f9..195b379 100644 --- a/schema.sql +++ b/schema.sql @@ -61,6 +61,7 @@ CREATE TABLE tees ( id SERIAL PRIMARY KEY, course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE, name VARCHAR(50) NOT NULL, -- F.eks. '64', 'Gul', 'Rød' + sort_order INTEGER NOT NULL DEFAULT 0, cr_men NUMERIC(4, 1), slope_men INTEGER, cr_women NUMERIC(4, 1), @@ -83,3 +84,5 @@ CREATE TABLE hole_lengths ( tee_id INTEGER REFERENCES tees(id) ON DELETE CASCADE, length_meters INTEGER ); + +CREATE UNIQUE INDEX hole_lengths_hole_tee_uidx ON hole_lengths (hole_id, tee_id);