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:
parent
d0167b3882
commit
db944bc119
2 changed files with 185 additions and 27 deletions
|
|
@ -2044,6 +2044,13 @@ function ScoringWizard({
|
|||
// 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.
|
||||
type ShotRecord = {
|
||||
id: string
|
||||
club: string
|
||||
distance_meters: number
|
||||
shared_round_message_id: string | null
|
||||
}
|
||||
|
||||
function ShotMeasurementEntry({
|
||||
roundId,
|
||||
holeNumber,
|
||||
|
|
@ -2064,21 +2071,36 @@ function ShotMeasurementEntry({
|
|||
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
|
||||
|
||||
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 [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [shareError, setShareError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(urlBase, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then((shots: unknown[]) => {
|
||||
if (!cancelled) setCount(shots.length)
|
||||
.then((data: ShotRecord[]) => {
|
||||
if (!cancelled) setShots(data)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [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
|
||||
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
|
||||
|
|
@ -2125,16 +2147,29 @@ function ShotMeasurementEntry({
|
|||
setSubmitError(message)
|
||||
return
|
||||
}
|
||||
const shot = await res.json()
|
||||
let shot = await res.json()
|
||||
setShareError(null)
|
||||
if (result.share) {
|
||||
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
|
||||
const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
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)
|
||||
} catch {
|
||||
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
|
||||
|
|
@ -2143,19 +2178,60 @@ function ShotMeasurementEntry({
|
|||
}
|
||||
}
|
||||
|
||||
if (readOnly) return null
|
||||
|
||||
const text = count > 0 ? `${label ? `${label}: ` : ""}${count} slag målt` : `${label ? `${label}: ` : ""}Mål et slag`
|
||||
async function shareExisting(shot: ShotRecord) {
|
||||
const res = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
|
||||
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 (
|
||||
<>
|
||||
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSubmitError(null)
|
||||
setOpen(true)
|
||||
setListOpen((o) => !o)
|
||||
}}
|
||||
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(
|
||||
"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"
|
||||
|
|
@ -2164,8 +2240,41 @@ function ShotMeasurementEntry({
|
|||
)}
|
||||
>
|
||||
<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
|
||||
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 && (
|
||||
<ShotMeasurementSheet
|
||||
holeNumber={holeNumber}
|
||||
|
|
@ -2177,7 +2286,56 @@ function ShotMeasurementEntry({
|
|||
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
Loading…
Reference in a new issue