Compare commits

..

No commits in common. "23bef24e18cbdd8e7cabc02d3d687a2649ef8f72" and "d0167b3882eb2aaa3f06bb408943c325ff315b12" have entirely different histories.

3 changed files with 27 additions and 241 deletions

View file

@ -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.

View file

@ -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