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>
424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
"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>
|
||
)
|
||
}
|