Compare commits
2 commits
70f6e92726
...
d0167b3882
| Author | SHA1 | Date | |
|---|---|---|---|
| d0167b3882 | |||
| 8da2423942 |
4 changed files with 152 additions and 59 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -9285,3 +9285,50 @@ Neste steg:
|
||||||
Med dette er ADR-048 (slag-for-slag GPS-avstandsmåling) fullt bygget,
|
Med dette er ADR-048 (slag-for-slag GPS-avstandsmåling) fullt bygget,
|
||||||
verifisert og live -- begge inngangspunkt, begge hull-eiertyper,
|
verifisert og live -- begge inngangspunkt, begge hull-eiertyper,
|
||||||
begge Mapbox-token-veier (kart-valg + delings-satellittbilde).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -2065,6 +2065,8 @@ function ShotMeasurementEntry({
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(urlBase, { credentials: "include" })
|
fetch(urlBase, { credentials: "include" })
|
||||||
|
|
@ -2078,6 +2080,11 @@ function ShotMeasurementEntry({
|
||||||
}
|
}
|
||||||
}, [urlBase])
|
}, [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: {
|
async function submitShot(result: {
|
||||||
club: string
|
club: string
|
||||||
distanceMeters: number
|
distanceMeters: number
|
||||||
|
|
@ -2089,6 +2096,9 @@ function ShotMeasurementEntry({
|
||||||
share: boolean
|
share: boolean
|
||||||
shareText: string
|
shareText: string
|
||||||
}) {
|
}) {
|
||||||
|
setSubmitError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
const res = await fetch(urlBase, {
|
const res = await fetch(urlBase, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -2104,7 +2114,15 @@ function ShotMeasurementEntry({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setOpen(false)
|
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
|
return
|
||||||
}
|
}
|
||||||
const shot = await res.json()
|
const shot = await res.json()
|
||||||
|
|
@ -2118,6 +2136,11 @@ function ShotMeasurementEntry({
|
||||||
}
|
}
|
||||||
setCount((n) => n + 1)
|
setCount((n) => n + 1)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
} catch {
|
||||||
|
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (readOnly) return null
|
if (readOnly) return null
|
||||||
|
|
@ -2130,6 +2153,7 @@ function ShotMeasurementEntry({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
setSubmitError(null)
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -2147,6 +2171,8 @@ function ShotMeasurementEntry({
|
||||||
holeNumber={holeNumber}
|
holeNumber={holeNumber}
|
||||||
existingShotCount={count}
|
existingShotCount={count}
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
|
submitError={submitError}
|
||||||
|
submitting={submitting}
|
||||||
onSubmit={submitShot}
|
onSubmit={submitShot}
|
||||||
onCancel={() => setOpen(false)}
|
onCancel={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ export type ShotMeasurementSheetProps = {
|
||||||
* stub is used purely for display.
|
* stub is used purely for display.
|
||||||
*/
|
*/
|
||||||
distanceMeters?: number
|
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: {
|
onSubmit: (result: {
|
||||||
club: string
|
club: string
|
||||||
distanceMeters: number
|
distanceMeters: number
|
||||||
|
|
@ -73,6 +78,8 @@ export function ShotMeasurementSheet({
|
||||||
existingShotCount,
|
existingShotCount,
|
||||||
ownBagClubs,
|
ownBagClubs,
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
|
submitError,
|
||||||
|
submitting,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ShotMeasurementSheetProps) {
|
}: ShotMeasurementSheetProps) {
|
||||||
|
|
@ -116,14 +123,12 @@ export function ShotMeasurementSheet({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
|
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
|
||||||
const endRequested = useRef(false)
|
// Deliberately NOT auto-fired on step mount (real bug found 2026-08-08:
|
||||||
useEffect(() => {
|
// firing immediately gave the user zero time to actually walk from the
|
||||||
if (step !== "end") {
|
// start point to the ball, so start/end ended up at ~the same spot and
|
||||||
endRequested.current = false
|
// ~the same instant -- distance was always 0m). Requires an explicit
|
||||||
return
|
// "Jeg er ved ballen nå" tap once the user has actually walked there.
|
||||||
}
|
function measureEndPoint() {
|
||||||
if (endRequested.current) return
|
|
||||||
endRequested.current = true
|
|
||||||
setEndStatus("loading")
|
setEndStatus("loading")
|
||||||
acquirePosition(
|
acquirePosition(
|
||||||
(p) => {
|
(p) => {
|
||||||
|
|
@ -133,7 +138,7 @@ export function ShotMeasurementSheet({
|
||||||
},
|
},
|
||||||
() => setEndStatus("error"),
|
() => setEndStatus("error"),
|
||||||
)
|
)
|
||||||
}, [step])
|
}
|
||||||
|
|
||||||
const distance =
|
const distance =
|
||||||
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
|
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
|
||||||
|
|
@ -145,18 +150,6 @@ export function ShotMeasurementSheet({
|
||||||
}
|
}
|
||||||
}, [step, club, distance, holeNumber])
|
}, [step, club, distance, holeNumber])
|
||||||
|
|
||||||
function retryEnd() {
|
|
||||||
setEndStatus("loading")
|
|
||||||
acquirePosition(
|
|
||||||
(p) => {
|
|
||||||
setEndPoint(p)
|
|
||||||
setEndStatus("idle")
|
|
||||||
setStep("club")
|
|
||||||
},
|
|
||||||
() => setEndStatus("error"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (step === "map" || step === "end") {
|
if (step === "map" || step === "end") {
|
||||||
setStartStatus("idle")
|
setStartStatus("idle")
|
||||||
|
|
@ -303,6 +296,22 @@ export function ShotMeasurementSheet({
|
||||||
|
|
||||||
{step === "end" ? (
|
{step === "end" ? (
|
||||||
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-10 text-center">
|
<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">
|
||||||
|
Gå 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 nå
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{endStatus === "loading" ? (
|
{endStatus === "loading" ? (
|
||||||
<>
|
<>
|
||||||
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
|
@ -320,7 +329,7 @@ export function ShotMeasurementSheet({
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Crosshair className="size-5" aria-hidden="true" />
|
||||||
|
|
@ -410,6 +419,12 @@ export function ShotMeasurementSheet({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{submitError ? (
|
||||||
|
<p role="alert" className="text-base text-destructive text-pretty">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -430,11 +445,16 @@ export function ShotMeasurementSheet({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={submitting}
|
||||||
onClick={confirmSubmit}
|
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"
|
||||||
>
|
>
|
||||||
|
{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" />
|
<Check className="size-5 shrink-0" aria-hidden="true" />
|
||||||
Lagre slag
|
)}
|
||||||
|
{submitting ? "Lagrer …" : "Lagre slag"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue