Compare commits

..

2 commits

Author SHA1 Message Date
43482d9ba9 Dokumenter slagmåling-inngangspunkt 2 i CHANGELOG.md
Punkt 46. Se forrige commit for koden.
2026-08-08 13:12:31 +02:00
4981c7eaed 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.
2026-08-08 13:12:25 +02:00
3 changed files with 221 additions and 82 deletions

View file

@ -9167,3 +9167,65 @@ Neste steg:
egen, enklere side-veiviser uten dette detalj-steget), ekte
Mapbox-tokens, og migrering av `060_round_shot.sql` mot ekte
`teecup_db` (krever egen, eksplisitt brukerbekreftelse).
46. **Slag-for-slag GPS-avstandsmåling — inngangspunkt 2 av 2, alltid-
synlig merkelapp på hull-kortet — 2026-08-08 (ADR-048).** Fullfører
v1-kravet om å dekke BEGGE hull-eiertyper fra start (punkt 45 dekket
kun deltaker-eide hull via `ScoringWizard`).
Refaktorerte punkt 45s inline logikk (state + fetch + submit, som satt
direkte i `ScoringWizard`) ut til en ny, delt lokal funksjon
`ShotMeasurementEntry` i `round-detail.tsx` -- samme
"lokal gjenbruk innad i filen, ikke en egen delt-fil"-mønster som
resten av filens komponenter (`NumberPicker`/`Stepper`/`ChoiceRow`
m.fl., se `DESIGN_SYSTEM.md`s "Komponentmønstre"-seksjon), siden alle
tre bruksstedene nå ligger i samme fil. Komponenten bygger riktig
URL-base (`.../participants/{id}/holes/{n}/shots` vs.
`.../sides/{id}/holes/{n}/shots`) fra en enkel `owner: {kind, id}`-
prop -- ingen egen gren utover selve URL-en trengs, ADR-048s speilede
endepunkt-par dekker begge hull-eiertyper identisk.
Koblet inn tre steder:
- `ScoringWizard`s detalj-steg (uendret fra punkt 45, nå bare kalt
via den delte komponenten i stedet for egen kopi).
- `PlayerHoleCards` (deltaker-eide hull, hoved-"Score"-fanens
spillerkort): en liten merkelapp-rad lagt til som SØSKEN av kortets
store klikkbare knapp (ikke nøstet inni -- ugyldig HTML/ARIA å neste
`<button>` i `<button>`), synlig uavhengig av om veiviseren er åpen,
for retroaktiv måling.
- `SideScorecardGrid` (side-eide hull, delt-ball-formater): siden selve
scorekort-gridet er en tett 52px-per-hull-tabell uten plass til en
egen knapp per rute, ble to merkelapper (én per side, med lag-
etikett, f.eks. "Rødt lag: 2 slag målt") lagt til som en egen rad
rett under gridet, ved siden av "Forrige/Neste hull"-knappene.
**Scratch-verifisert i ekte nettleser** (sekstende scratch-miljø
denne økten, samme migrasjon-002-forsiktighet, `teecup_db` bekreftet
uendret) -- **med et ekte Mapbox-token satt inn i `.env` av brukeren
midt i denne runden** (`NEXT_PUBLIC_MAPBOX_TOKEN`, offentlig/URL-
restriktert; `TEECUP_MAPBOX_SECRET_TOKEN` for delings-satellittbilder
fortsatt ikke mottatt): opprettet én vanlig slagspill-runde (deltaker-
eid) og én foursome-runde med to sider (side-eid, via egen-opprettede
sider + en gjest lagt til side B) direkte mot API-et. Bekreftet
merkelappen vises korrekt og uavhengig av kortknappen i
`PlayerHoleCards`, åpner arket direkte (ikke via veiviseren). Testet
"Velg punkt på kart"-veien med det ekte tokenet -- Mapbox avviste med
403 (URL-restriksjonen tillater kun det ekte domenet, ikke dette
scratch-miljøets `localhost`-opprinnelse), som bekreftet at
feilhåndteringen i `MapPointPicker` fungerer rent (ren "Kunne ikke
laste kartet"-melding, ingen krasj) -- selve kart-SUKSESS-stien kunne
ikke click-through-testes i dette miljøet av samme grunn. For
side-eide hull: bekreftet begge merkelapper ("Blått lag"/"Rødt lag")
henter riktig antall fra sine respektive `.../sides/{id}/holes/{n}/
shots`-endepunkt (bekreftet i API-loggen), og at et slag opprettet på
én side kun oppdaterer DEN sidens merkelapp, ikke den andres. Samme
GPS-tillatelse-miljøbegrensning som punkt 45 gjaldt fortsatt for
GPS-suksess-stien; de eksakte `submitShot`-kallene ble derfor igjen
verifisert direkte mot API-et for begge eiertyper. Scratch-miljøet
ryddet opp fullstendig.
**Ikke rullet ut** -- samme gjenstående blokkere som punkt 45:
`round_shot`-tabellen finnes ikke i ekte `teecup_db` ennå
(migrasjonen aldri kjørt der, krever egen brukerbekreftelse), og
`TEECUP_MAPBOX_SECRET_TOKEN` (for delings-satellittbilder) mangler
fortsatt.

View file

@ -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