Compare commits

..

2 commits

Author SHA1 Message Date
d0167b3882 Dokumenter slagmåling-produksjonsbugfiksen i CHANGELOG.md
Punkt 49. Se forrige commit for koden.
2026-08-08 15:06:59 +02:00
8da2423942 Fiks: slagmåling ga alltid 0m, delinger forsvant stille
Produksjonsbug rapportert av bruker rett etter forrige utrulling: "den
målte aldri mer enn 0 meter. Jeg så ingen bilder."

Rotårsak 1: "end"-steget (ballens posisjon) avfyrte GPS-målingen
automatisk idet steget ble aktivt, uten tid for brukeren til faktisk å
gå fra utslagsstedet til ballen -- start/slutt endte på samme sted,
samme øyeblikk. Fjernet auto-avfyringen, krever nå et eksplisitt "Jeg
er ved ballen nå"-trykk.

Rotårsak 2 (konsekvens av #1): en 0m-avstand avvist av backendens
distance_meters > 0-validering (422) gjorde at submitShot() bare lukket
arket stille, ingen feilmelding. Lagt til submitError/submitting-state,
parser både appens {detail:{message}} og FastAPI/Pydantic sin rå
{detail:[{msg}]}-valideringsform (bekreftet eksakt hvilken ved å
trigge en ekte 422), viser feilen i arket, holder det åpent ved feil.

Scratch-verifisert med navigator.geolocation midlertidig overstyrt
(ekte GPS-tillatelse permanent denied i CDP-automatiserte nettleser-
sesjoner): "Jeg er ved ballen nå" vises korrekt, et ekte gap ga
"130 m" (ikke 0) og delingen postet riktig, og en 0m-innsending viser nå
feilmeldingen i stedet for å lukke stille. Se CHANGELOG.md punkt 49.
2026-08-08 15:06:48 +02:00
4 changed files with 152 additions and 59 deletions

View file

@ -9285,3 +9285,50 @@ Neste steg:
Med dette er ADR-048 (slag-for-slag GPS-avstandsmåling) fullt bygget,
verifisert og live -- begge inngangspunkt, begge hull-eiertyper,
begge Mapbox-token-veier (kart-valg + delings-satellittbilde).
49. **PRODUKSJONSBUG: slagmåling ga alltid 0 meter, delinger forsvant
stille — 2026-08-08, brukerrapport rett etter punkt 48s utrulling.**
Brukeren: "den målte aldri mer enn 0 meter. Jeg så ingen bilder."
**Rotårsak 1 (selve 0-meter-buggen):** `shot-measurement-sheet.tsx`
sitt "end"-steg (ballens posisjon) avfyrte GPS-målingen AUTOMATISK i
et `useEffect` idet steget ble aktivt -- rett etter at startpunktet
var målt, med null tid for brukeren til faktisk å gå fra utslagsstedet
til ballen. Start- og sluttpunkt endte dermed på praktisk talt samme
sted, samme øyeblikk -- avstanden ble alltid ~0m, uavhengig av hvor
langt slaget faktisk var. Rettet ved å fjerne auto-avfyringen og
kreve et eksplisitt "Jeg er ved ballen nå"-trykk (samme mønster som
"Prøv igjen" ved feil, nå gjenbrukt for begge) -- brukeren går fysisk
til ballen FØR målingen skjer, i stedet for at appen antar de allerede
er der.
**Rotårsak 2 (stille tap, "ingen bilder"):** en konsekvens av
rotårsak 1 -- en 0m-avstand ble avvist av backendens
`distance_meters > 0`-validering (422), men `submitShot()` i
`round-detail.tsx` sin `ShotMeasurementEntry` gjorde da bare
`setOpen(false)` og returnerte -- INGEN feilmelding, arket lukket seg
stille som om alt var i orden. Brukeren fikk aldri vite at slaget
(og dermed en eventuell deling) aldri ble lagret. Rettet: `submitShot`
setter nå en `submitError`-state (parser backendens feilrespons,
som kan være enten appens vanlige `{detail:{message}}`-form ELLER
FastAPI/Pydantic sin rå valideringsform `{detail:[{msg,...}]}` --
bekreftet eksakt hvilken av de to ved å faktisk trigge en 422 mot
scratch-API-et: det er listeformen), viser feilen i arket
(`role="alert"`, ny `submitError`/`submitting`-prop på
`ShotMeasurementSheetProps`), og holder arket ÅPENT ved feil i stedet
for å anta suksess og lukke.
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (nittende
scratch-miljø denne økten, samme migrasjon-002-forsiktighet,
`teecup_db` bekreftet uendret): CDP-automatiserte nettlesersesjoner
har `geolocation`-tillatelse permanent `denied` i dette miljøet (samme
begrensning som punkt 45-46), så `navigator.geolocation.
getCurrentPosition` ble midlertidig overstyrt via `evaluate_script`
for å drive den EKTE React-komponentens fulle flyt (ikke bare et
API-nivå-kall) -- bekreftet "Jeg er ved ballen nå"-knappen faktisk
vises i stedet for å auto-måle, at et ekte gap mellom start-/
sluttkoordinat ga "MÅLT LENGDE: 130 m" (ikke 0), at delingen faktisk
postet riktig tekst til `/rounds/{id}/messages`, OG (motsatt test,
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.

View file

@ -2065,6 +2065,8 @@ function ShotMeasurementEntry({
const [open, setOpen] = useState(false)
const [count, setCount] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(urlBase, { credentials: "include" })
@ -2078,6 +2080,11 @@ function ShotMeasurementEntry({
}
}, [urlBase])
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
// holder arket åpent i stedet for å anta suksess.
async function submitShot(result: {
club: string
distanceMeters: number
@ -2089,35 +2096,51 @@ function ShotMeasurementEntry({
share: boolean
shareText: string
}) {
const res = await fetch(urlBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
setOpen(false)
return
}
const shot = await res.json()
if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
setSubmitError(null)
setSubmitting(true)
try {
const res = await fetch(urlBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const detail = body?.detail
const message =
typeof detail === "string"
? detail
: typeof detail?.message === "string"
? detail.message
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
setSubmitError(message)
return
}
const shot = await res.json()
if (result.share) {
await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
}
setCount((n) => n + 1)
setOpen(false)
} catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSubmitting(false)
}
setCount((n) => n + 1)
setOpen(false)
}
if (readOnly) return null
@ -2130,6 +2153,7 @@ function ShotMeasurementEntry({
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
className={cn(
@ -2147,6 +2171,8 @@ function ShotMeasurementEntry({
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>

View file

@ -39,6 +39,11 @@ export type ShotMeasurementSheetProps = {
* stub is used purely for display.
*/
distanceMeters?: number
/** Vises som en feilmelding i resultat-steget, f.eks. når serveren avviste
* innsendingen (ADR-048: reell bug 2026-08-08 -- uten dette lukket arket
* seg stille ved en avvist innsending, ingen feil synlig for brukeren). */
submitError?: string | null
submitting?: boolean
onSubmit: (result: {
club: string
distanceMeters: number
@ -73,6 +78,8 @@ export function ShotMeasurementSheet({
existingShotCount,
ownBagClubs,
distanceMeters,
submitError,
submitting,
onSubmit,
onCancel,
}: ShotMeasurementSheetProps) {
@ -116,36 +123,12 @@ export function ShotMeasurementSheet({
}
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
const endRequested = useRef(false)
useEffect(() => {
if (step !== "end") {
endRequested.current = false
return
}
if (endRequested.current) return
endRequested.current = true
setEndStatus("loading")
acquirePosition(
(p) => {
setEndPoint(p)
setEndStatus("idle")
setStep("club")
},
() => setEndStatus("error"),
)
}, [step])
const distance =
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
// Prefill the (editable) share text once the result step is reached.
useEffect(() => {
if (step === "result" && !shareEdited.current) {
setShareText(`${club || "slag"}, ${distance} m hull ${holeNumber}`)
}
}, [step, club, distance, holeNumber])
function retryEnd() {
// Deliberately NOT auto-fired on step mount (real bug found 2026-08-08:
// firing immediately gave the user zero time to actually walk from the
// start point to the ball, so start/end ended up at ~the same spot and
// ~the same instant -- distance was always 0m). Requires an explicit
// "Jeg er ved ballen nå" tap once the user has actually walked there.
function measureEndPoint() {
setEndStatus("loading")
acquirePosition(
(p) => {
@ -157,6 +140,16 @@ export function ShotMeasurementSheet({
)
}
const distance =
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
// Prefill the (editable) share text once the result step is reached.
useEffect(() => {
if (step === "result" && !shareEdited.current) {
setShareText(`${club || "slag"}, ${distance} m hull ${holeNumber}`)
}
}, [step, club, distance, holeNumber])
function goBack() {
if (step === "map" || step === "end") {
setStartStatus("idle")
@ -303,6 +296,22 @@ export function ShotMeasurementSheet({
{step === "end" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-10 text-center">
{endStatus === "idle" ? (
<>
<p className="text-base text-muted-foreground text-pretty">
til der ballen ligger, og trykk når du er fremme.
</p>
<button
type="button"
onClick={measureEndPoint}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
Jeg er ved ballen
</button>
</>
) : null}
{endStatus === "loading" ? (
<>
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
@ -320,7 +329,7 @@ export function ShotMeasurementSheet({
</p>
<button
type="button"
onClick={retryEnd}
onClick={measureEndPoint}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5" aria-hidden="true" />
@ -410,6 +419,12 @@ export function ShotMeasurementSheet({
</div>
) : null}
</div>
{submitError ? (
<p role="alert" className="text-base text-destructive text-pretty">
{submitError}
</p>
) : null}
</div>
) : null}
</main>
@ -430,11 +445,16 @@ export function ShotMeasurementSheet({
) : (
<button
type="button"
disabled={submitting}
onClick={confirmSubmit}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<Check className="size-5 shrink-0" aria-hidden="true" />
Lagre slag
{submitting ? (
<div className="size-5 shrink-0 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
) : (
<Check className="size-5 shrink-0" aria-hidden="true" />
)}
{submitting ? "Lagrer …" : "Lagre slag"}
</button>
)}
</footer>

File diff suppressed because one or more lines are too long