Slag-for-slag GPS-avstandsmåling: liste over egne slag + fiks stille del-feil

Brukertilbakemelding: "jeg burde jo se slaget selv, selv om det ikke er
delt" -- merkelappen viste tidligere kun et antall, aldri kølle/avstand
for de faktiske slagene. ShotMeasurementEntry lagrer nå hele slag-listen
(ikke bare .length), med en ny utvidbar ShotList (kølle+avstand, "Delt"-
merke eller "Del"-lenke for et ikke-delt slag, slette-knapp per slag via
det allerede eksisterende DELETE-endepunktet). Splittet den ene knappen
i en utvidbar "N slag målt"-disclosure og en separat "+"-knapp for nye
målinger.

Tredje stille-feil-bug funnet og rettet i samme runde (samme klasse som
forrige commit): re-del-fra-liste sjekket heller ikke svaret sitt. Lagt
til egen shareError-state, bevisst atskilt fra submitError (som holder
selve måle-arket åpent ved avvist innsending) -- siden slaget allerede
er lagret når en delingsfeil kan oppstå, ville gjenbruk av submitError
risikert et duplikat-slag ved gjentatt "Lagre slag".

Scratch-verifisert: to slag opprettet, listen viser riktig kølle/
avstand, re-del fra listen fungerer (bekreftet ekte melding postet),
sletting fjerner slaget fra listen. Se CHANGELOG.md punkt 50.
This commit is contained in:
Erol Haagenrud 2026-08-08 16:45:13 +02:00
parent d0167b3882
commit db944bc119
2 changed files with 185 additions and 27 deletions

View file

@ -2044,6 +2044,13 @@ function ScoringWizard({
// en egen, enklere side-veiviser uten dette steget). Samme // en egen, enklere side-veiviser uten dette steget). Samme
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede // urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
// deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en. // deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en.
type ShotRecord = {
id: string
club: string
distance_meters: number
shared_round_message_id: string | null
}
function ShotMeasurementEntry({ function ShotMeasurementEntry({
roundId, roundId,
holeNumber, holeNumber,
@ -2064,21 +2071,36 @@ function ShotMeasurementEntry({
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots` const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [count, setCount] = useState(0) // 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 [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
fetch(urlBase, { credentials: "include" }) fetch(urlBase, { credentials: "include" })
.then((res) => (res.ok ? res.json() : [])) .then((res) => (res.ok ? res.json() : []))
.then((shots: unknown[]) => { .then((data: ShotRecord[]) => {
if (!cancelled) setCount(shots.length) if (!cancelled) setShots(data)
}) })
.catch(() => {}) .catch(() => {})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [urlBase]) }, [urlBase])
const count = shots.length
async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${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 // Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for // distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
@ -2125,16 +2147,29 @@ function ShotMeasurementEntry({
setSubmitError(message) setSubmitError(message)
return return
} }
const shot = await res.json() let shot = await res.json()
setShareError(null)
if (result.share) { if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, { const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: result.shareText }), 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 = await shareRes.json()
} else {
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
} }
setCount((n) => n + 1) }
setShots((prev) => [...prev, shot])
setOpen(false) setOpen(false)
} catch { } catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.") setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
@ -2143,19 +2178,60 @@ function ShotMeasurementEntry({
} }
} }
if (readOnly) return null async function shareExisting(shot: ShotRecord) {
const res = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
const text = count > 0 ? `${label ? `${label}: ` : ""}${count} slag målt` : `${label ? `${label}: ` : ""}Mål et slag` 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 = 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 ( return (
<> <div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setSubmitError(null) setListOpen((o) => !o)
setOpen(true)
}} }}
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( 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", "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" variant === "inline"
@ -2164,8 +2240,41 @@ function ShotMeasurementEntry({
)} )}
> >
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" /> <Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{text} {label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button> </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 && ( {open && (
<ShotMeasurementSheet <ShotMeasurementSheet
holeNumber={holeNumber} holeNumber={holeNumber}
@ -2177,7 +2286,56 @@ function ShotMeasurementEntry({
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
/> />
)} )}
</> </div>
)
}
function ShotList({
shots,
onDelete,
onShare,
}: {
shots: ShotRecord[]
onDelete?: (shotId: string) => void
onShare?: (shot: ShotRecord) => void
}) {
return (
<ul className="flex w-full min-w-[14rem] flex-col gap-1 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => (
<li key={shot.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
<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_round_message_id ? (
<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>
) )
} }

File diff suppressed because one or more lines are too long