Compare commits
2 commits
2d1d0ba1d0
...
43482d9ba9
| Author | SHA1 | Date | |
|---|---|---|---|
| 43482d9ba9 | |||
| 4981c7eaed |
3 changed files with 221 additions and 82 deletions
62
CHANGELOG.md
62
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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