teecup/frontend/components/shot/shot-measurement-entry.tsx
Erol Haagenrud 885dcac4e5 Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103)
Ny tournament_round_shot-tabell (org-scopet RLS, FK-et til
tournament_round_participant + hole_number siden tournament_round_hole
kanskje ikke finnes ennå). ShotMeasurementSheet/MapPointPicker gjenbrukt
uendret; ShotMeasurementEntry generalisert fra en lokal round-detail.tsx-
funksjon til en delt, URL-parameterisert komponent. Deling går til den
rundespesifikke kommentartråden (ADR-102) i stedet for Banter Board.
Autorisasjon speiler update_hole (self/org-admin), strengere enn ADR-102s
kommentar-nivå.

Migrasjon 091 IKKE kjørt mot ekte teecup_db ennå -- venter på
brukerbekreftelse før utrulling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 07:16:14 +02:00

424 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, unknown>): 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<ShotRecord[]>([])
const [listOpen, setListOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(shotsUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: Record<string, unknown>[]) => {
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<number | undefined>(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 (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
</div>
)
}
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<div className="flex items-center gap-1.5">
{count > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
aria-label={addLabel}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
count > 0
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
: variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
{count === 0 && addLabel}
</button>
</div>
{shareError && (
<p role="alert" className="text-xs font-medium text-destructive">
{shareError}
</p>
)}
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
{open && (
<ShotMeasurementSheet
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>
)}
</div>
)
}
// 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 (
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => {
const thumbUrl = shotThumbnailUrl(shot)
return (
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
{thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={thumbUrl || "/placeholder.svg"}
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
className="size-14 shrink-0 rounded-lg border border-border object-cover"
/>
) : null}
<span className="flex-1 truncate font-medium text-foreground">
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
</span>
{shot.shared ? (
<span className="text-[11px] font-semibold text-primary">Delt</span>
) : onShare ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onShare(shot)
}}
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
>
Del
</button>
) : null}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul>
)
}