Compare commits
No commits in common. "23bef24e18cbdd8e7cabc02d3d687a2649ef8f72" and "d0167b3882eb2aaa3f06bb408943c325ff315b12" have entirely different histories.
23bef24e18
...
d0167b3882
3 changed files with 27 additions and 241 deletions
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -9332,59 +9332,3 @@ Neste steg:
|
||||||
identisk start-/sluttkoordinat) at en avvist 0m-innsending nå viser
|
identisk start-/sluttkoordinat) at en avvist 0m-innsending nå viser
|
||||||
feilmeldingen i arket i stedet for å lukke seg stille. Scratch-miljøet
|
feilmeldingen i arket i stedet for å lukke seg stille. Scratch-miljøet
|
||||||
ryddet opp fullstendig.
|
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,13 +2044,6 @@ 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,
|
||||||
|
|
@ -2071,36 +2064,21 @@ 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)
|
||||||
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
|
const [count, setCount] = useState(0)
|
||||||
// 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((data: ShotRecord[]) => {
|
.then((shots: unknown[]) => {
|
||||||
if (!cancelled) setShots(data)
|
if (!cancelled) setCount(shots.length)
|
||||||
})
|
})
|
||||||
.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
|
||||||
|
|
@ -2147,29 +2125,16 @@ function ShotMeasurementEntry({
|
||||||
setSubmitError(message)
|
setSubmitError(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let shot = await res.json()
|
const shot = await res.json()
|
||||||
setShareError(null)
|
|
||||||
if (result.share) {
|
if (result.share) {
|
||||||
const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
|
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.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShots((prev) => [...prev, shot])
|
setCount((n) => n + 1)
|
||||||
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.")
|
||||||
|
|
@ -2178,103 +2143,29 @@ function ShotMeasurementEntry({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareExisting(shot: ShotRecord) {
|
if (readOnly) return null
|
||||||
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) {
|
const text = count > 0 ? `${label ? `${label}: ` : ""}${count} slag målt` : `${label ? `${label}: ` : ""}Mål et slag`
|
||||||
// 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 (
|
return (
|
||||||
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
|
<>
|
||||||
<div className="flex items-center gap-1.5">
|
<button
|
||||||
{count > 0 && (
|
type="button"
|
||||||
<button
|
onClick={(e) => {
|
||||||
type="button"
|
e.stopPropagation()
|
||||||
onClick={(e) => {
|
setSubmitError(null)
|
||||||
e.stopPropagation()
|
setOpen(true)
|
||||||
setListOpen((o) => !o)
|
}}
|
||||||
}}
|
className={cn(
|
||||||
aria-expanded={listOpen}
|
"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",
|
||||||
className={cn(
|
variant === "inline"
|
||||||
"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",
|
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
|
||||||
variant === "inline"
|
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
|
||||||
? "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>
|
|
||||||
)}
|
)}
|
||||||
<button
|
>
|
||||||
type="button"
|
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
|
||||||
onClick={(e) => {
|
{text}
|
||||||
e.stopPropagation()
|
</button>
|
||||||
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}
|
||||||
|
|
@ -2286,56 +2177,7 @@ 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
Loading…
Reference in a new issue