teecup/frontend/components/shot/shot-measurement-sheet.tsx
Erol Haagenrud 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

466 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useRef, useState } from "react"
import dynamic from "next/dynamic"
import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-react"
import { ClubPicker } from "@/components/teecup/club-picker"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
/**
* Lazy, client-only reference to the map step. Because dynamic() defers the
* underlying import() until <MapPointPicker /> is actually rendered, neither
* this module nor a GPS-only run ever pulls in mapbox-gl. A Mapbox map load
* (which is billed) only happens once the user chooses "Velg punkt på kart".
*/
const MapPointPicker = dynamic(
() => import("@/components/shot/map-point-picker").then((m) => m.MapPointPicker),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
),
},
)
type LngLat = { lng: number; lat: number }
type Step = "start" | "map" | "end" | "club" | "result"
type GeoStatus = "idle" | "loading" | "error"
export type ShotMeasurementSheetProps = {
holeNumber: number
existingShotCount: number
ownBagClubs: string[]
/**
* Computed by the caller (Haversine) once both points are known. Optional
* here so the sheet stays previewable; when absent a rough preview-only
* 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
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endLat: number
endLng: number
share: boolean
shareText: string
}) => void
onCancel: () => void
}
// Preview-only rough distance so the result step shows a real number in v0.
// The real app passes distanceMeters computed server-/caller-side.
function previewDistance(a: LngLat | null, b: LngLat | null): number {
if (!a || !b) return 0
const R = 6371000
const toRad = (d: number) => (d * Math.PI) / 180
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const h =
Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2
return Math.round(2 * R * Math.asin(Math.sqrt(h)))
}
export function ShotMeasurementSheet({
holeNumber,
existingShotCount,
ownBagClubs,
distanceMeters,
submitError,
submitting,
onSubmit,
onCancel,
}: ShotMeasurementSheetProps) {
const [step, setStep] = useState<Step>("start")
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
const [startPoint, setStartPoint] = useState<LngLat | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
const [club, setClub] = useState<string>("")
const [share, setShare] = useState(false)
const [shareText, setShareText] = useState("")
const shareEdited = useRef(false)
function acquirePosition(onOk: (p: LngLat) => void, onFail: () => void) {
if (typeof navigator === "undefined" || !navigator.geolocation) {
onFail()
return
}
navigator.geolocation.getCurrentPosition(
(pos) => onOk({ lng: pos.coords.longitude, lat: pos.coords.latitude }),
() => onFail(),
{ enableHighAccuracy: true, timeout: 10000 },
)
}
// Start via device GPS.
function useMyPosition() {
setStartStatus("loading")
acquirePosition(
(p) => {
setStartPoint(p)
setStartMethod("gps")
setStartStatus("idle")
setStep("end")
},
() => setStartStatus("error"),
)
}
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
// 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) => {
setEndPoint(p)
setEndStatus("idle")
setStep("club")
},
() => setEndStatus("error"),
)
}
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")
setEndStatus("idle")
setStep("start")
} else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null)
setStep("end")
} else if (step === "result") {
setStep("club")
}
}
function confirmSubmit() {
if (!startPoint || !endPoint || !club) return
onSubmit({
club,
distanceMeters: distance,
startMethod: startMethod ?? "gps",
startLat: startPoint.lat,
startLng: startPoint.lng,
endLat: endPoint.lat,
endLng: endPoint.lng,
share,
shareText: share ? shareText : "",
})
}
const onMapStep = step === "map"
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{ fontFamily: "var(--font-nunito), ui-sans-serif, system-ui, sans-serif" }}
role="dialog"
aria-modal="true"
aria-label={`Mål et slag, hull ${holeNumber}`}
>
{/* Header */}
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-start gap-2">
{step !== "start" ? (
<button
type="button"
onClick={goBack}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
Tilbake
</button>
) : null}
<div className="pt-1.5">
<h1 className="text-lg font-bold leading-tight text-balance">
{`Mål et slag — hull ${holeNumber}`}
</h1>
{existingShotCount > 0 ? (
<p className="mt-0.5 text-sm text-muted-foreground">
{`${existingShotCount} slag målt tidligere`}
</p>
) : null}
</div>
</div>
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-5 shrink-0" aria-hidden="true" />
Lukk
</button>
</header>
{/* Main */}
<main className={cn("flex-1", onMapStep ? "overflow-hidden" : "overflow-y-auto p-4")}>
{step === "start" ? (
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
<p className="text-base text-muted-foreground text-pretty">
Velg hvor slaget startet. Ballens sluttposisjon måles alltid med GPS.
</p>
<button
type="button"
onClick={useMyPosition}
disabled={startStatus === "loading"}
className="flex min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<LocateFixed className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Min posisjon </span>
<span className="text-sm text-muted-foreground">Bruk telefonens GPS</span>
</span>
</button>
<button
type="button"
onClick={() => {
setStartMethod("map_tap")
setStep("map")
}}
className="flex min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<MapPin className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Velg punkt kart</span>
<span className="text-sm text-muted-foreground">Trykk satellittkartet</span>
</span>
</button>
{startStatus === "loading" ? (
<div className="flex items-center justify-center gap-3 pt-2">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Finner posisjonen din </p>
</div>
) : null}
{startStatus === "error" ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<p role="alert" className="text-base text-destructive text-pretty">
Fant ikke posisjonen din. Sjekk at posisjonstjenester er , og prøv igjen.
</p>
<button
type="button"
onClick={useMyPosition}
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" />
Prøv igjen
</button>
</div>
) : null}
</div>
) : null}
{step === "map" ? <MapPointPicker onConfirm={(p) => {
setStartPoint(p)
setStep("end")
}} /> : null}
{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" />
<p className="text-base text-muted-foreground">Finner posisjonen din </p>
<p className="text-sm text-muted-foreground text-pretty">
Stå ved ballen mens vi måler sluttpunktet.
</p>
</>
) : null}
{endStatus === "error" ? (
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left">
<p role="alert" className="text-base text-destructive text-pretty">
Kunne ikke måle ballposisjonen. Prøv igjen når du står ved ballen.
</p>
<button
type="button"
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" />
Prøv igjen
</button>
</div>
) : null}
</div>
) : null}
{step === "club" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold">Hvilken kølle brukte du?</h2>
<p className="text-base text-muted-foreground">Velg køllen for dette slaget.</p>
</div>
{/* Reuses the shared ClubPicker (imported, not rebuilt). */}
<ClubPicker value={club} onChange={setClub} ownBagClubs={ownBagClubs} />
</div>
) : null}
{step === "result" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Målt lengde
</span>
<span className="text-6xl font-extrabold tabular-nums leading-none">
{distance}
<span className="ml-1 text-3xl font-bold text-muted-foreground">m</span>
</span>
{club ? (
<span className="mt-2 inline-flex items-center rounded-full bg-muted px-3 py-1 text-sm font-semibold text-foreground">
{club}
</span>
) : null}
</div>
{/* Private vs share segmented control */}
<div className="flex flex-col gap-3">
<span className="text-sm font-semibold text-muted-foreground">Deling</span>
<div
role="radiogroup"
aria-label="Deling av slaget"
className="grid grid-cols-2 gap-2 rounded-full bg-muted p-1"
>
{[
{ key: false, label: "Behold privat" },
{ key: true, label: "Del i feeden" },
].map((opt) => {
const selected = share === opt.key
return (
<button
key={String(opt.key)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setShare(opt.key)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded-full px-4 text-base font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
)
})}
</div>
{share ? (
<div className="flex flex-col gap-2">
<label htmlFor="share-text" className="text-sm font-semibold text-muted-foreground">
Innlegg
</label>
<Textarea
id="share-text"
value={shareText}
onChange={(e) => {
shareEdited.current = true
setShareText(e.target.value)
}}
rows={3}
/>
</div>
) : null}
</div>
{submitError ? (
<p role="alert" className="text-base text-destructive text-pretty">
{submitError}
</p>
) : null}
</div>
) : null}
</main>
{/* Footer — hidden on map step (MapPointPicker owns its own confirm bar)
and on the transient GPS steps. */}
{step === "club" || step === "result" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{step === "club" ? (
<button
type="button"
disabled={!club.trim()}
onClick={() => setStep("result")}
className="inline-flex min-h-14 w-full items-center justify-center 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"
>
Neste
</button>
) : (
<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 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" />
)}
{submitting ? "Lagrer …" : "Lagre slag"}
</button>
)}
</footer>
) : null}
</div>
)
}
export default ShotMeasurementSheet