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:
parent
2d1d0ba1d0
commit
4981c7eaed
2 changed files with 159 additions and 82 deletions
|
|
@ -1522,6 +1522,22 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
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">
|
||||
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
|
||||
<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">
|
||||
<PlayerHoleCards
|
||||
roundId={roundId}
|
||||
players={players}
|
||||
holesByParticipant={holesByParticipant}
|
||||
holeOrder={holeOrder}
|
||||
|
|
@ -1559,6 +1576,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
sides={round.sides}
|
||||
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
|
||||
formatResult={formatResult}
|
||||
ownBagClubs={ownBagClubs}
|
||||
onSelectHole={(n) => {
|
||||
setCurrentHole(n)
|
||||
scrollToHolePanel()
|
||||
|
|
@ -1738,71 +1756,6 @@ function ScoringWizard({
|
|||
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
||||
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 [stepIndex, setStepIndex] = useState(0)
|
||||
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
|
||||
|
|
@ -2007,16 +1960,14 @@ function ScoringWizard({
|
|||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShotSheetOpen(true)}
|
||||
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"
|
||||
>
|
||||
<Ruler className="size-5 shrink-0" aria-hidden="true" />
|
||||
{shotCount > 0 ? `Mål et slag (${shotCount} målt)` : "Mål et slag"}
|
||||
</button>
|
||||
)}
|
||||
<ShotMeasurementEntry
|
||||
roundId={roundId}
|
||||
holeNumber={hole.holeNumber}
|
||||
owner={{ kind: "participant", id: player.id }}
|
||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||||
readOnly={readOnly}
|
||||
variant="inline"
|
||||
/>
|
||||
|
||||
{hole.par !== 3 && (
|
||||
<DirectionCross
|
||||
|
|
@ -2080,17 +2031,127 @@ function ScoringWizard({
|
|||
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||||
</Button>
|
||||
</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
|
||||
holeNumber={hole.holeNumber}
|
||||
existingShotCount={shotCount}
|
||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||||
holeNumber={holeNumber}
|
||||
existingShotCount={count}
|
||||
ownBagClubs={ownBagClubs}
|
||||
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
|
||||
// V0-prompt.
|
||||
function PlayerHoleCards({
|
||||
roundId,
|
||||
players,
|
||||
holesByParticipant,
|
||||
holeOrder,
|
||||
|
|
@ -5001,11 +5063,13 @@ function PlayerHoleCards({
|
|||
sides,
|
||||
isTwoSided,
|
||||
formatResult,
|
||||
ownBagClubs,
|
||||
onSelectHole,
|
||||
onPrevHole,
|
||||
onNextHole,
|
||||
onOpenEntry,
|
||||
}: {
|
||||
roundId: string
|
||||
players: Player[]
|
||||
holesByParticipant: Record<string, ApiHole[]>
|
||||
holeOrder: number[]
|
||||
|
|
@ -5015,6 +5079,7 @@ function PlayerHoleCards({
|
|||
sides: ApiSide[]
|
||||
isTwoSided: boolean
|
||||
formatResult: ApiFormatResult | null
|
||||
ownBagClubs: string[]
|
||||
onSelectHole: (holeNumber: number) => void
|
||||
onPrevHole: () => void
|
||||
onNextHole: () => void
|
||||
|
|
@ -5250,6 +5315,18 @@ function PlayerHoleCards({
|
|||
)}
|
||||
</div>
|
||||
</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>
|
||||
</li>
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue