Slag-for-slag GPS-avstandsmåling: inngangspunkt 2 — merkelapp på hull-kortet (ADR-048)

Fullfører v1-kravet om å dekke begge hull-eiertyper fra start (forrige
commit dekket kun deltaker-eide hull via ScoringWizard). Refaktorerte
den inline logikken ut til en delt lokal funksjon ShotMeasurementEntry
(samme "lokal gjenbruk innad i filen"-mønster som resten av filens
komponenter) -- bygger riktig URL-base for enten
.../participants/{id}/holes/{n}/shots eller .../sides/{id}/holes/{n}/
shots fra en enkel owner:{kind,id}-prop.

Koblet inn i PlayerHoleCards (deltaker-eide hull, som søsken av kortets
store klikkbare knapp -- ikke nøstet, ugyldig å neste <button> i
<button>) og SideScorecardGrid (side-eide hull -- to merkelapper med
lag-etikett rett under scorekort-gridet, siden selve tabellen er for
tett til en knapp per rute).

Bruker satte inn et ekte Mapbox-token (NEXT_PUBLIC_MAPBOX_TOKEN) i .env
midt i denne runden. Scratch-verifisert med det ekte tokenet: kart-veien
avvist med 403 (URL-restriksjon mot scratch-miljøets localhost-
opprinnelse) -- bekreftet at feilhåndteringen i MapPointPicker fungerer
rent i stedet for å krasje. Begge merkelapper bekreftet henter og
oppdaterer riktig, uavhengig av hverandre, for både deltaker- og
side-eide hull.

Se CHANGELOG.md punkt 46 for full verifiseringsdetalj.
This commit is contained in:
Erol Haagenrud 2026-08-08 13:12:25 +02:00
parent 2d1d0ba1d0
commit 4981c7eaed
2 changed files with 159 additions and 82 deletions

View file

@ -1522,6 +1522,22 @@ export function RoundDetail({ roundId }: { roundId: string }) {
setWizardSideId(sideId) setWizardSideId(sideId)
}} }}
/> />
{!readOnly && round.sides.length === 2 && (
<div className="flex flex-wrap justify-center gap-2">
{round.sides.map((s, i) => (
<ShotMeasurementEntry
key={s.id}
roundId={roundId}
holeNumber={activeHole}
owner={{ kind: "side", id: s.id }}
ownBagClubs={ownBagClubs}
readOnly={readOnly}
variant="badge"
label={s.label?.trim() || (i === 0 ? "Side A" : "Side B")}
/>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold"> <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" /> <ChevronLeft aria-hidden="true" className="size-5" />
@ -1550,6 +1566,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
) : ( ) : (
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4"> <section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
<PlayerHoleCards <PlayerHoleCards
roundId={roundId}
players={players} players={players}
holesByParticipant={holesByParticipant} holesByParticipant={holesByParticipant}
holeOrder={holeOrder} holeOrder={holeOrder}
@ -1559,6 +1576,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
sides={round.sides} sides={round.sides}
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)} isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult} formatResult={formatResult}
ownBagClubs={ownBagClubs}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setCurrentHole(n)
scrollToHolePanel() scrollToHolePanel()
@ -1738,71 +1756,6 @@ function ScoringWizard({
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- kun
// deltaker-eide hull (denne veiviseren brukes utelukkende for
// per-spiller-formater, se ScoringWizard sin egen kommentar over --
// delt-ball-formater bruker en egen, enklere side-veiviser uten dette
// steget). `shotCount` lastes på nytt hver gang hull/spiller endres.
const [shotSheetOpen, setShotSheetOpen] = useState(false)
const [shotCount, setShotCount] = useState(0)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((shots: unknown[]) => {
if (!cancelled) setShotCount(shots.length)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, player.id, hole.holeNumber])
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endLat: number
endLng: number
share: boolean
shareText: string
}) {
const res = await fetch(
`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`,
{
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_lat: result.endLat,
end_lng: result.endLng,
}),
},
)
if (!res.ok) {
setShotSheetOpen(false)
return
}
const shot = await res.json()
if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
}
setShotCount((n) => n + 1)
setShotSheetOpen(false)
}
const steps = wizardStepsFor(player.statLevel) const steps = wizardStepsFor(player.statLevel)
const [stepIndex, setStepIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0)
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller -- // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
@ -2007,16 +1960,14 @@ function ScoringWizard({
readOnly={readOnly} readOnly={readOnly}
/> />
{!readOnly && ( <ShotMeasurementEntry
<button roundId={roundId}
type="button" holeNumber={hole.holeNumber}
onClick={() => setShotSheetOpen(true)} owner={{ kind: "participant", id: player.id }}
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ownBagClubs={player.isSelf ? ownBagClubs : []}
> readOnly={readOnly}
<Ruler className="size-5 shrink-0" aria-hidden="true" /> variant="inline"
{shotCount > 0 ? `Mål et slag (${shotCount} målt)` : "Mål et slag"} />
</button>
)}
{hole.par !== 3 && ( {hole.par !== 3 && (
<DirectionCross <DirectionCross
@ -2080,17 +2031,127 @@ function ScoringWizard({
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"} {isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
</Button> </Button>
</footer> </footer>
</div>
)
}
{shotSheetOpen && ( // Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- delt mellom
// tre bruksteder: ScoringWizard sitt detalj-steg (inline-variant,
// deltaker-eid hull), PlayerHoleCards sin alltid-synlige merkelapp
// (badge-variant, deltaker-eid, retroaktiv måling), og SideScorecardGrid
// sin tilsvarende merkelapp per side (badge-variant, side-eid hull --
// dekker delt-ball-formatene som ScoringWizard aldri når siden de bruker
// en egen, enklere side-veiviser uten dette steget). Samme
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
// deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en.
function ShotMeasurementEntry({
roundId,
holeNumber,
owner,
ownBagClubs,
readOnly,
variant,
label,
}: {
roundId: string
holeNumber: number
owner: { kind: "participant" | "side"; id: string }
ownBagClubs: string[]
readOnly: boolean
variant: "inline" | "badge"
label?: string
}) {
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
const [open, setOpen] = useState(false)
const [count, setCount] = useState(0)
useEffect(() => {
let cancelled = false
fetch(urlBase, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((shots: unknown[]) => {
if (!cancelled) setCount(shots.length)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [urlBase])
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endLat: number
endLng: number
share: boolean
shareText: string
}) {
const res = await fetch(urlBase, {
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_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
setOpen(false)
return
}
const shot = await res.json()
if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
}
setCount((n) => n + 1)
setOpen(false)
}
if (readOnly) return null
const text = count > 0 ? `${label ? `${label}: ` : ""}${count} slag målt` : `${label ? `${label}: ` : ""}Mål et slag`
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
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" />
{text}
</button>
{open && (
<ShotMeasurementSheet <ShotMeasurementSheet
holeNumber={hole.holeNumber} holeNumber={holeNumber}
existingShotCount={shotCount} existingShotCount={count}
ownBagClubs={player.isSelf ? ownBagClubs : []} ownBagClubs={ownBagClubs}
onSubmit={submitShot} onSubmit={submitShot}
onCancel={() => setShotSheetOpen(false)} onCancel={() => setOpen(false)}
/> />
)} )}
</div> </>
) )
} }
@ -4992,6 +5053,7 @@ function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes
// -- selve registreringsvinduet er bevisst IKKE rørt av denne rundens // -- selve registreringsvinduet er bevisst IKKE rørt av denne rundens
// V0-prompt. // V0-prompt.
function PlayerHoleCards({ function PlayerHoleCards({
roundId,
players, players,
holesByParticipant, holesByParticipant,
holeOrder, holeOrder,
@ -5001,11 +5063,13 @@ function PlayerHoleCards({
sides, sides,
isTwoSided, isTwoSided,
formatResult, formatResult,
ownBagClubs,
onSelectHole, onSelectHole,
onPrevHole, onPrevHole,
onNextHole, onNextHole,
onOpenEntry, onOpenEntry,
}: { }: {
roundId: string
players: Player[] players: Player[]
holesByParticipant: Record<string, ApiHole[]> holesByParticipant: Record<string, ApiHole[]>
holeOrder: number[] holeOrder: number[]
@ -5015,6 +5079,7 @@ function PlayerHoleCards({
sides: ApiSide[] sides: ApiSide[]
isTwoSided: boolean isTwoSided: boolean
formatResult: ApiFormatResult | null formatResult: ApiFormatResult | null
ownBagClubs: string[]
onSelectHole: (holeNumber: number) => void onSelectHole: (holeNumber: number) => void
onPrevHole: () => void onPrevHole: () => void
onNextHole: () => void onNextHole: () => void
@ -5250,6 +5315,18 @@ function PlayerHoleCards({
)} )}
</div> </div>
</button> </button>
{!readOnly && (
<div className="flex justify-end border-t border-border px-4 py-2">
<ShotMeasurementEntry
roundId={roundId}
holeNumber={currentHole}
owner={{ kind: "participant", id: player.id }}
ownBagClubs={player.isSelf ? ownBagClubs : []}
readOnly={readOnly}
variant="badge"
/>
</div>
)}
</div> </div>
</li> </li>
) )

File diff suppressed because one or more lines are too long