"use client" import { useEffect, useMemo, useState } from "react" import { ChevronDown, Plus, Ruler, Trash2 } from "lucide-react" import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { bearingDegrees } from "@/lib/geo" import { midpoint } from "@/lib/hole-geometry" import { cn } from "@/lib/utils" /** * Slag-for-slag GPS-avstandsmåling (ADR-048), UI-laget som binder * `ShotMeasurementSheet`/`MapPointPicker` (begge helt generiske, ingen * endring) mot et faktisk backend-endepunkt. Generalisert 2026-08-22 * (ADR-103) fra en lokal funksjon i round-detail.tsx til denne delte * filen -- kalleren bygger nå URL-ene selv i stedet for at komponenten * antar `/rounds/{roundId}/...`-formen, slik at BÅDE frittstående * runder OG org-turneringer (helt ulike REST-stier) kan gjenbruke den * uendret. */ export type ShotRecord = { id: string club: string distance_meters: number shared: boolean start_lat: number start_lng: number end_lat: number end_lng: number } /** De to backendene navngir delings-lenken ulikt (`shared_round_message_id` * vs. `shared_tournament_round_message_id`, ulike måltabeller) -- * normaliseres her til ett felles `shared`-flagg, resten av feltene er * identiske på tvers av begge API-kontraktene. */ function normalizeShot(raw: Record): ShotRecord { return { id: raw.id as string, club: raw.club as string, distance_meters: raw.distance_meters as number, shared: Boolean(raw.shared_round_message_id ?? raw.shared_tournament_round_message_id), start_lat: raw.start_lat as number, start_lng: raw.start_lng as number, end_lat: raw.end_lat as number, end_lng: raw.end_lng as number, } } export type ShotMeasurementEntryProps = { /** GET (liste) + POST (opprett) -- fullt sti, f.eks. * `/rounds/{roundId}/participants/{id}/holes/{n}/shots`. */ shotsUrl: string /** Rundescopet base for DELETE `${x}/${id}` og POST `${x}/${id}/share` * -- f.eks. `/rounds/{roundId}/shots`. */ shotsRoundBaseUrl: string /** Banegeometri (utslag->green) for kartretning -- utelates helt for * en bane uten kjent geometri, faller da tilbake til forrige-slag- * retning (uendret atferd). */ targetPointsUrl?: string holeNumber: number ownBagClubs: string[] readOnly: boolean variant: "inline" | "badge" label?: string } export function ShotMeasurementEntry({ shotsUrl, shotsRoundBaseUrl, targetPointsUrl, holeNumber, ownBagClubs, readOnly, variant, label, }: ShotMeasurementEntryProps) { const [open, setOpen] = useState(false) // Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om // det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen // måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen // (ikke bare .length), viser den i en utvidbar liste, med sletting av et // feilmålt slag. const [shots, setShots] = useState([]) const [listOpen, setListOpen] = useState(false) const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) const [shareError, setShareError] = useState(null) useEffect(() => { let cancelled = false fetch(shotsUrl, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: Record[]) => { if (!cancelled) setShots(data.map(normalizeShot)) }) .catch(() => {}) return () => { cancelled = true } }, [shotsUrl]) const count = shots.length // Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE // utslags- OG green-koordinater kjent (samme datakilde som // HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID // følge utslag->green, ikke gjettes fra spillerens forrige slag (som // kan avvike, f.eks. en slice/hook). const [courseBearing, setCourseBearing] = useState(undefined) useEffect(() => { let cancelled = false setCourseBearing(undefined) if (!targetPointsUrl) return fetch(targetPointsUrl, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => { if (cancelled) return const middle = points.find((p) => p.poi_type === "green" && p.location === "middle") const teeFront = points.find((p) => p.poi_type === "tee_front") const teeBack = points.find((p) => p.poi_type === "tee_back") const teeRef = teeFront && teeBack ? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude }) : teeFront ? { lat: teeFront.latitude, lng: teeFront.longitude } : teeBack ? { lat: teeBack.latitude, lng: teeBack.longitude } : null setCourseBearing( teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined, ) }) .catch(() => { if (!cancelled) setCourseBearing(undefined) }) return () => { cancelled = true } }, [targetPointsUrl]) // Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet // 2026-08-21): "opp på skjermen" følger banens EGEN spillretning // (utslag->green, courseBearing over) når den er kjent -- ellers // faller tilbake til spillerens EGEN gangretning, regnet ut fra det // SISTE tidligere målte slaget på dette hullet (start→slutt-retningen). // `undefined` (ingen rotasjon regnet ut ennå) for det aller første // slaget på et hull uten kjent banegeometri -- MapPointPicker faller // da selv tilbake til enhetens GPS-heading/nord. const initialBearing = useMemo(() => { if (typeof courseBearing === "number") return courseBearing const previous = shots[shots.length - 1] if (!previous) return undefined return bearingDegrees( { lat: previous.start_lat, lng: previous.start_lng }, { lat: previous.end_lat, lng: previous.end_lng }, ) }, [shots, courseBearing]) async function deleteShot(shotId: string) { const res = await fetch(`${shotsRoundBaseUrl}/${shotId}`, { method: "DELETE", credentials: "include" }) if (res.ok) { setShots((prev) => prev.filter((s) => s.id !== shotId)) } } // Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens // distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for // fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så // aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og // holder arket åpent i stedet for å anta suksess. async function submitShot(result: { club: string distanceMeters: number startMethod: "gps" | "map_tap" startLat: number startLng: number endMethod: "gps" | "map_tap" endLat: number endLng: number share: boolean shareText: string }) { setSubmitError(null) setSubmitting(true) try { const res = await fetch(shotsUrl, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ club: result.club, distance_meters: result.distanceMeters, start_method: result.startMethod, start_lat: result.startLat, start_lng: result.startLng, end_method: result.endMethod, end_lat: result.endLat, end_lng: result.endLng, }), }) if (!res.ok) { const body = await res.json().catch(() => null) const detail = body?.detail const message = typeof detail === "string" ? detail : typeof detail?.message === "string" ? detail.message : "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen." setSubmitError(message) return } let shot = normalizeShot(await res.json()) setShareError(null) if (result.share) { const shareRes = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ body: result.shareText }), }) // Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en // avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt // gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er // uansett lagret på dette tidspunktet -- arket lukkes derfor alltid // her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og // en mislykket deling vises separat ved siden av merkelappen i // stedet for å holde arket åpent. if (shareRes.ok) { shot = normalizeShot(await shareRes.json()) } else { setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.") } } setShots((prev) => [...prev, shot]) setOpen(false) } catch { setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.") } finally { setSubmitting(false) } } async function shareExisting(shot: ShotRecord) { const res = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ body: `⛳ ${shot.club}, ${shot.distance_meters} m – hull ${holeNumber}` }), }) if (res.ok) { const updated = normalizeShot(await res.json()) setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s))) setShareError(null) } else { setShareError("Delingen feilet. Prøv igjen om litt.") } } if (readOnly) { // Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE // egne målte slag, bare uten å kunne måle/dele/slette nye. if (count === 0) return null return (
{listOpen && }
) } const addLabel = label ? `${label}: Mål et slag` : "Mål et slag" return (
{count > 0 && ( )}
{shareError && (

{shareError}

)} {listOpen && count > 0 && } {open && ( setOpen(false)} /> )}
) } // Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg // burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen // viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme // mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt // resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token, // ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord. function shotThumbnailUrl(shot: ShotRecord): string | null { if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}` } function ShotList({ shots, onDelete, onShare, }: { shots: ShotRecord[] onDelete?: (shotId: string) => void onShare?: (shot: ShotRecord) => void }) { return (
    {shots.map((shot) => { const thumbUrl = shotThumbnailUrl(shot) return (
  • {thumbUrl ? ( // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt {`Satellittutsnitt ) : null} {shot.club} — {shot.distance_meters} m {shot.shared ? ( Delt ) : onShare ? ( ) : null} {onDelete && ( )}
  • ) })}
) }