teecup/frontend/components/shot/shot-measurement-sheet.tsx
Erol Haagenrud 17fac05ddc Alt kompilerer rent. Oppsummering av denne runden:
1. WHS Rule 3.1b — ferdig, testet (117/117), rullet ut... nei, venter fortsatt (se under).
2. round.tee_name_snapshot-synk — ferdig, scratch-verifisert.
3. Optimistisk versjonssjekk for samtidig hull-redigering — ferdig bygget, og jeg fant og rettet en reell UX-bug underveis (en fullskjerm-feilvisning i stedet for en liten banner) som ellers ville gjort funksjonen verre enn ingenting.

Alt er dokumentert i ADR-056/057 og CHANGELOG punkt 65–66.

Deploy — rekkefølgen har betydning (migrasjonen må kjøre først, siden ny kode leser version-kolonnen):

# 1. Migrasjon mot ekte teecup_db
docker exec teeoff_db psql -U teeoff_admin -d teecup_db -f /opt/teecup/062_round_hole_version.sql

# 2. Backend
docker compose build teecup_api
docker compose up -d teecup_api

# 3. Frontend
docker compose build teecup_frontend
docker compose up -d teecup_frontend
Og fortsatt uavklart fra tidligere: datakorreksjonen for din spesifikke runde

UPDATE round SET tee_name_snapshot = '55'
WHERE id = '520f9681-6e7f-49bf-8504-9ab223874285';
2026-08-10 18:34:31 +02:00

454 lines
18 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"
import { haversineMeters } from "@/lib/geo"
/**
* 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[]
/**
* Kompassgrader (0-360) kartet roteres til, regnet ut av kalleren fra
* spillerens EGET forrige slag på dette hullet (start→slutt-retningen) --
* gir "opp på skjermen = fremover på hullet" uten lagrede green-
* koordinater (brukerønske 2026-08-10). `undefined` når dette er det
* første slaget som måles på hullet (ingen forrige slag å regne fra) --
* MapPointPicker faller da selv tilbake til enhetens GPS-heading eller
* nord, se den komponentens egen dokumentasjon.
*/
initialBearing?: number
/**
* 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
endMethod: "gps" | "map_tap"
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
return Math.round(haversineMeters(a, b))
}
export function ShotMeasurementSheet({
holeNumber,
existingShotCount,
ownBagClubs,
initialBearing,
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 [endMethod, setEndMethod] = useState<"gps" | "map_tap" | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = 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"),
)
}
const distance =
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
// Forhåndsvisning av satellittutsnittet FØR lagring (brukerønske
// 2026-08-08, ikke opprinnelig planlagt -- selve DELINGEN genererer sitt
// eget, server-side bilde med samme markører+linje uavhengig av dette).
// Bygget client-side med det OFFENTLIGE tokenet (trygt i nettleseren,
// URL-restriktert) -- ingen server-tur-retur nødvendig kun for en
// forhåndsvisning.
const previewUrl =
startPoint && endPoint && process.env.NEXT_PUBLIC_MAPBOX_TOKEN
? `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${startPoint.lng},${startPoint.lat}),pin-s-b+2f7a3f(${endPoint.lng},${endPoint.lat})/auto/500x300@2x?padding=50&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
: null
// 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")
setStep("start")
} else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null)
setEndMethod(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,
endMethod: endMethod ?? "gps",
endLat: endPoint.lat,
endLng: endPoint.lng,
share,
shareText: share ? shareText : "",
})
}
const onMapStep = step === "map" || step === "end"
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.
</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}
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
"map_tap" på start-steget siden ingen referencePoint er satt. */}
{step === "map" ? (
<MapPointPicker
initialBearing={initialBearing}
onConfirm={(p) => {
setStartPoint(p)
setStep("end")
}}
/>
) : null}
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
brukerønske: "jeg skal se start og slutt på et satelittfoto...
mens jeg går opp mot ballen", "jeg ønsker å se lengden så langt
i sanntid"). Ingen egen valg-skjerm lenger -- MapPointPicker
selv viser BÅDE "Bekreft ballens posisjon" (trykket punkt) og
"Jeg er ved ballen nå" (nåværende sporede posisjon) i footeren
når referencePoint er satt, se den komponenten. */}
{step === "end" ? (
<MapPointPicker
referencePoint={startPoint ?? undefined}
referenceLabel="Utslag"
initialBearing={initialBearing}
onConfirm={(p, method) => {
setEndPoint(p)
setEndMethod(method)
setStep("club")
}}
/>
) : 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">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={previewUrl || "/placeholder.svg"}
alt="Satellittutsnitt av slaget, med start- og sluttpunkt markert"
className="w-full rounded-2xl border border-border object-cover"
/>
) : null}
<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