Compare commits
2 commits
d0167b3882
...
23bef24e18
| Author | SHA1 | Date | |
|---|---|---|---|
| 23bef24e18 | |||
| db944bc119 |
3 changed files with 241 additions and 27 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -9332,3 +9332,59 @@ Neste steg:
|
|||
identisk start-/sluttkoordinat) at en avvist 0m-innsending nå viser
|
||||
feilmeldingen i arket i stedet for å lukke seg stille. Scratch-miljøet
|
||||
ryddet opp fullstendig.
|
||||
|
||||
50. **Slag-for-slag GPS-avstandsmåling: liste over egne slag + fant en
|
||||
TREDJE stille-feil-bug — 2026-08-08, brukeroppfølging.** Brukeren
|
||||
prøvde på nytt etter punkt 49s fiks (bekreftet: målte nå 13 meter,
|
||||
ikke 0), men spurte "jeg kan ikke se det delte slaget i ettertid?".
|
||||
|
||||
**Diagnose (skrivebeskyttede spørringer mot ekte `teecup_db` + ekte
|
||||
API-logger):** slaget lå riktig i `round_shot` (13m, ikke 0 -- punkt
|
||||
49s fiks virket), men `shared_round_message_id` var tom, og
|
||||
`round_message`-tabellen hadde null rader for runden. API-loggene
|
||||
viste at `POST .../shots` ga 201 Created, men det fantes INGEN
|
||||
etterfølgende kall til del-endepunktet i det hele tatt. Konklusjon:
|
||||
"Del i feeden"-valget var ikke aktivt idet brukeren trykket "Lagre
|
||||
slag" (arket starter forfra, inkl. tilbake til "Behold privat"
|
||||
-standardvalget, hver gang det åpnes på nytt -- forklart til
|
||||
brukeren).
|
||||
|
||||
Brukerens motspørsmål var det egentlig viktige: **et privat, ikke-delt
|
||||
slag burde uansett kunne SES i ettertid** -- merkelappen viste
|
||||
tidligere kun et tall ("N slag målt"), aldri kølle/avstand for de
|
||||
faktiske slagene. Dette var en reell mangel i inngangspunkt 2 (punkt
|
||||
46), ikke bare en brukerforvirring.
|
||||
|
||||
**Bygget:** `ShotMeasurementEntry` lagrer nå hele slag-listen (ikke
|
||||
bare `.length`), med en ny utvidbar `ShotList` (kølle + avstand per
|
||||
slag, "Delt"-merke eller en "Del"-lenke for et ikke-delt slag, en
|
||||
slette-knapp per slag via det allerede eksisterende
|
||||
`DELETE /rounds/{id}/shots/{shot_id}`-endepunktet). Splittet den
|
||||
tidligere ene knappen i to: en utvidbar "N slag målt"-disclosure
|
||||
(kun synlig når `count > 0`) og en separat, alltid synlig "+"-knapp
|
||||
for å måle et NYTT slag -- unngår at å åpne listen og å starte en ny
|
||||
måling er samme handling.
|
||||
|
||||
**Tredje stille-feil-bug funnet og rettet i samme runde:** akkurat
|
||||
som punkt 49s rotårsak 2, sjekket heller ikke re-del-fra-liste-kallet
|
||||
(`shareExisting`) svaret sitt. Rettet parallelt med bygningen av
|
||||
listen, IKKE etter en ny brukerrapport denne gangen. En mislykket
|
||||
deling (enten ved førstegangs innsending eller re-del fra listen)
|
||||
vises nå som en egen, kortvarig feiltekst ved siden av merkelappen
|
||||
(`shareError`-state) -- BEVISST atskilt fra `submitError` (som
|
||||
fortsatt holder selve MÅLE-arket åpent ved en avvist innsending):
|
||||
siden selve slaget alerede er lagret på tidspunktet en delingsfeil
|
||||
kan oppstå, ville gjenbruk av `submitError` (som holder arket åpent)
|
||||
risikert at brukeren trykker "Lagre slag" på nytt og oppretter et
|
||||
duplikat-slag.
|
||||
|
||||
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (tjuende
|
||||
scratch-miljø denne økten, samme migrasjon-002-forsiktighet,
|
||||
`teecup_db` bekreftet uendret): opprettet to slag direkte mot API-et
|
||||
(Driver 215,5m, 7-jern 145m), bekreftet merkelappen viser "2 slag
|
||||
målt" som en egen utvidbar knapp ved siden av en separat "+"-knapp,
|
||||
utvidet listen og så begge slag med riktig kølle/avstand, klikket
|
||||
"Del" på Driver-slaget og bekreftet det ble til "Delt" OG at meldingen
|
||||
faktisk postet riktig tekst til `/rounds/{id}/messages`, slettet
|
||||
7-jern-slaget og bekreftet det forsvant fra listen ("1 slag målt"
|
||||
etterpå). Scratch-miljøet ryddet opp fullstendig.
|
||||
|
|
|
|||
|
|
@ -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,29 +2178,103 @@ function ShotMeasurementEntry({
|
|||
}
|
||||
}
|
||||
|
||||
if (readOnly) return null
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
const text = count > 0 ? `${label ? `${label}: ` : ""}${count} slag målt` : `${label ? `${label}: ` : ""}Mål et slag`
|
||||
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()
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSubmitError(null)
|
||||
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",
|
||||
<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"
|
||||
? "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" />
|
||||
{label ? `${label}: ` : ""}
|
||||
{count} slag målt
|
||||
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
|
||||
{text}
|
||||
</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