HCP: type="number" blokkerte komma-tegnet i nettleseren før JS-parsingen
noensinne så det; byttet til text+inputMode=decimal og lagt til manglende
.replace(",",".") i tre lagre-stier. Deltakertabell: "Rediger i
spillerpoolen" er en full sidenavigasjon, bfcache kunne gjenopprette
turneringssiden i utdatert tilstand -- løst med stille bakgrunns-refresh
ved visibilitychange/pageshow. Spillerpool: zebra-striper lagt tilbake
oppå rutenett-stilen fra forrige runde.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7925 lines
338 KiB
TypeScript
7925 lines
338 KiB
TypeScript
"use client"
|
||
|
||
// Hull-for-hull-registrering for en frittstående runde (ADR-033).
|
||
// Presentasjon fra V0, datalag skrevet om fra mock til ekte fetch/PATCH.
|
||
//
|
||
// VIKTIG kontraktsdetalj (bekreftet i scratch): hull-PATCH-endepunktet er
|
||
// IKKE et ekte delvis-PATCH -- det skriver ALLE felt ved hvert kall. Derfor
|
||
// slår updateStat() alltid sammen med gjeldende hull-data FØR den sender,
|
||
// aldri kun det ene feltet som ble endret.
|
||
|
||
import type React from "react"
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import Link from "next/link"
|
||
import dynamic from "next/dynamic"
|
||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||
import { RoundPageShell } from "@/components/round-page-shell"
|
||
import { RoundMessages } from "@/components/round-messages"
|
||
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||
import {
|
||
ArrowRight,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronsUpDown,
|
||
Clock,
|
||
Trash2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Crosshair,
|
||
Flag,
|
||
MapPin,
|
||
Plus,
|
||
RefreshCw,
|
||
Ruler,
|
||
Search,
|
||
Settings2,
|
||
Trophy,
|
||
Users,
|
||
WifiOff,
|
||
X,
|
||
} from "lucide-react"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import { cn } from "@/lib/utils"
|
||
import { ClubPicker } from "@/components/teecup/club-picker"
|
||
import {
|
||
ChoiceRow,
|
||
DirectionCross,
|
||
golfTermForScore,
|
||
HoleHistoryPanel,
|
||
NumberPicker,
|
||
Stepper,
|
||
WizardSection,
|
||
} from "@/components/hole-stat-inputs"
|
||
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
||
import { bearingDegrees } from "@/lib/geo"
|
||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||
|
||
// --- Types -----------------------------------------------------------------
|
||
|
||
type Gender = "male" | "female" | "other"
|
||
type ApiGender = "m" | "f" | "x"
|
||
type TeeShot = "left" | "fairway" | "right"
|
||
type Approach = "left" | "short" | "hit" | "long" | "right"
|
||
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
|
||
type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
|
||
|
||
type Player = {
|
||
id: string
|
||
// Alltid den faktiske display_name (2026-07-27: fjernet "Deg"-erstatning
|
||
// her -- se playerLabel() -- brøt "roster-kontekst → fullt navn" og ga en
|
||
// "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
|
||
name: string
|
||
userId: string | null
|
||
guestFirstName: string | null
|
||
guestLastName: string | null
|
||
guestEmail: string | null
|
||
isOwner: boolean
|
||
gender: Gender
|
||
hcp: number | null
|
||
// Tildelte slag for RUNDEN (course handicap, ikke selve HCP-indeksen) --
|
||
// null når HCP ikke spores for spilleren (2026-07-26).
|
||
courseHandicap: number | null
|
||
teeName: string
|
||
isSelf: boolean
|
||
countsForHandicap: boolean
|
||
scoreDifferential: number | null
|
||
statLevel: StatLevel
|
||
// ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av
|
||
// countsForHandicap (den automatiske kvalifiseringen).
|
||
excludeFromHandicap: boolean
|
||
// ADR-039 -- hvilken side (match/fourball/foursome/greensome/scramble),
|
||
// og den format-relative match-play-enheten (ikke samme som courseHandicap).
|
||
roundSideId: string | null
|
||
playingHandicap: number | null
|
||
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
|
||
lineupOrder: number | null
|
||
// ADR-065 -- se ApiParticipant.completed.
|
||
completed: boolean
|
||
}
|
||
|
||
type HoleStat = {
|
||
played: boolean
|
||
strokes: number | null
|
||
// "Plukket opp" (migrasjon 038, Stableford) -- serveren regner selv ut
|
||
// og skriver Net Double Bogey som `strokes` når dette er sant; feltet
|
||
// her er KUN til visning ("Plukket opp" i stedet for tallet).
|
||
pickedUp: boolean
|
||
putts: number | null
|
||
club: string
|
||
teeShot: TeeShot | null
|
||
approach: Approach | null
|
||
chip: number
|
||
bunker: number
|
||
penalty: number
|
||
firstPuttBucket: PuttBucket | null
|
||
anywayStrokes: number | null
|
||
}
|
||
|
||
type Hole = {
|
||
holeNumber: number
|
||
par: number
|
||
index: number
|
||
}
|
||
|
||
function emptyStat(): HoleStat {
|
||
return {
|
||
played: false,
|
||
strokes: null,
|
||
pickedUp: false,
|
||
putts: null,
|
||
club: "",
|
||
teeShot: null,
|
||
approach: null,
|
||
chip: 0,
|
||
bunker: 0,
|
||
penalty: 0,
|
||
firstPuttBucket: null,
|
||
anywayStrokes: null,
|
||
}
|
||
}
|
||
|
||
function statKey(playerId: string, holeNumber: number) {
|
||
return `${playerId}:${holeNumber}`
|
||
}
|
||
|
||
// Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som
|
||
// round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for
|
||
// akkumulert score-så-langt per spiller i den nye scoringsflyten).
|
||
function formatToPar(value: number): string {
|
||
if (value === 0) return "E"
|
||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||
}
|
||
|
||
function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null {
|
||
if (!holes) return null
|
||
const played = holes.filter((h) => h.played && h.score !== null)
|
||
if (played.length === 0) return null
|
||
const score = played.reduce((sum, h) => sum + (h.score as number), 0)
|
||
const par = played.reduce((sum, h) => sum + h.par, 0)
|
||
return { toPar: score - par, holesPlayed: played.length }
|
||
}
|
||
|
||
function apiGenderToUi(g: ApiGender): Gender {
|
||
return g === "m" ? "male" : g === "f" ? "female" : "other"
|
||
}
|
||
function uiGenderToApi(g: Gender): ApiGender {
|
||
return g === "male" ? "m" : g === "female" ? "f" : "x"
|
||
}
|
||
|
||
// --- API-typer ---------------------------------------------------------------
|
||
|
||
type ApiParticipant = {
|
||
id: string
|
||
user_id: string | null
|
||
guest_name: string | null
|
||
guest_first_name: string | null
|
||
guest_last_name: string | null
|
||
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
|
||
guest_email: string | null
|
||
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
|
||
// visningsnavn for en lenket bruker (eier eller medspiller).
|
||
display_name: string
|
||
is_owner: boolean
|
||
gender: ApiGender
|
||
// Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
|
||
// deltakers, redigerbart av eieren kun for DENNE runden.
|
||
tee_name_snapshot: string
|
||
handicap_index_snapshot: number | null
|
||
course_handicap_snapshot: number | null
|
||
counts_for_handicap: boolean
|
||
score_differential: number | null
|
||
stat_level: StatLevel
|
||
exclude_from_handicap: boolean
|
||
// ADR-039 -- hvilken side deltakeren er på (match/fourball/foursome/
|
||
// greensome/scramble), null for slagspill/skins (ingen sider).
|
||
round_side_id: string | null
|
||
// ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som
|
||
// course_handicap_snapshot (den absolutte WHS-verdien).
|
||
playing_handicap: number | null
|
||
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
|
||
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
|
||
lineup_order: number | null
|
||
// Per-deltaker-fullføring (ADR-065, migrasjon 066) -- UAVHENGIG av
|
||
// round.completed_at, kun en nagging-brems + e-post-trigger for DENNE
|
||
// spilleren, ikke en cascade til hele rundens fullføring.
|
||
completed: boolean
|
||
}
|
||
|
||
type ApiSide = {
|
||
id: string
|
||
label: string | null
|
||
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
|
||
// formater, som ikke skiller sidene fra hverandre strukturelt.
|
||
side_role: "team" | "individual" | null
|
||
}
|
||
|
||
type PersonMatch = {
|
||
id: string
|
||
first_name: string
|
||
last_name: string
|
||
avatar_url: string | null
|
||
home_club: string | null
|
||
handicap_index: number | null
|
||
}
|
||
|
||
type ApiTeeOption = {
|
||
name: string
|
||
genders: ApiGender[]
|
||
}
|
||
|
||
type ApiRound = {
|
||
id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
tee_name_snapshot: string
|
||
played_at: string
|
||
start_hole: number
|
||
holes_planned: number
|
||
play_format: string
|
||
skins_scoring: string | null
|
||
skins_tie_handling: string | null
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participants: ApiParticipant[]
|
||
sides: ApiSide[]
|
||
setup_complete: boolean
|
||
setup_message: string | null
|
||
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
|
||
visibility_mode: string
|
||
visible_categories: string[]
|
||
// Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
|
||
flight_group_id: string | null
|
||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||
flag_map_visible: boolean
|
||
}
|
||
|
||
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
||
// sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering
|
||
// fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen).
|
||
const VISIBILITY_CATEGORY_OPTIONS = [
|
||
{ code: "spouse", label: "Make" },
|
||
{ code: "close_family", label: "Nær familie" },
|
||
{ code: "extended_family", label: "Storfamilie" },
|
||
{ code: "close_friends", label: "Nære venner" },
|
||
{ code: "golf_friends", label: "Golfvenner" },
|
||
{ code: "colleagues", label: "Kollegaer" },
|
||
{ code: "business", label: "Forretningsforbindelser" },
|
||
{ code: "classmates", label: "Studiekamerater" },
|
||
{ code: "acquaintances", label: "Perifere bekjente" },
|
||
{ code: "other", label: "Ymse" },
|
||
] as const
|
||
|
||
// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
|
||
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
||
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
||
// server-side -- disse styrer bare presentasjon.
|
||
// Åtte nye formater (2026-07-30). Chapman er to-sidet+delt-ball (som
|
||
// foursome). High-low-high er to-sidet+individuell-ball (som fourball).
|
||
// Copenhagen/BBB/Flag/Shamble/Money Ball er ALDRI to-sidet (flatt felt,
|
||
// se ADR/plan) -- faller derfor gjennom til vanlig individuell-ball
|
||
// scorekort/veiviser (samme som stroke/stableford/skins), med egne
|
||
// tilleggsvisninger i FormatResultPanel.
|
||
const TWO_SIDED_FORMATS = new Set([
|
||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||
])
|
||
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
|
||
// Scramble mot enkeltspiller (migrasjon 056/057) -- HALVPARTEN delt-ball
|
||
// (laget deler ball via round_side_id) og HALVPARTEN individuell
|
||
// (motstanderen scorer per deltaker via round_participant_id). Passer
|
||
// derfor IKKE inn i SHARED_BALL_FORMATS sin alt-eller-ingenting-bryter --
|
||
// se scrambleSoloTeamSideId lenger ned, som ruter KUN lagets spillere til
|
||
// SideScoreWizard, mens individualspilleren fortsatt bruker vanlig
|
||
// ScoringWizard.
|
||
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
|
||
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||
match: 1,
|
||
fourball: 2,
|
||
foursome: 2,
|
||
greensome: 2,
|
||
scramble_2: 2,
|
||
scramble_4: 4,
|
||
chapman: 2,
|
||
high_low_high: 2,
|
||
}
|
||
const FORMAT_LABELS: Record<string, string> = {
|
||
stroke: "Slagspill",
|
||
stableford: "Stableford",
|
||
match: "Match",
|
||
skins: "Skins",
|
||
fourball: "Fourball",
|
||
foursome: "Foursome",
|
||
greensome: "Greensome",
|
||
scramble_2: "Scramble (2)",
|
||
scramble_4: "Scramble (4)",
|
||
chapman: "Chapman",
|
||
copenhagen: "Københavner",
|
||
bbb: "Bingo Bango Bongo",
|
||
flag: "Flaggturnering",
|
||
shamble: "Shamble",
|
||
money_ball: "Money Ball",
|
||
high_low_high: "High-low-high",
|
||
scramble_solo: "Scramble mot enkeltspiller",
|
||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||
}
|
||
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
|
||
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
|
||
const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "money_ball"])
|
||
|
||
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
||
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
||
// lagret varighet, kun utledet ved visning.
|
||
function formatDuration(startedAt: string, completedAt: string): string | null {
|
||
const start = new Date(startedAt).getTime()
|
||
const end = new Date(completedAt).getTime()
|
||
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null
|
||
const totalMinutes = Math.round((end - start) / 60000)
|
||
const hours = Math.floor(totalMinutes / 60)
|
||
const minutes = totalMinutes % 60
|
||
if (hours === 0) return `${minutes} min`
|
||
return `${hours}t ${minutes}min`
|
||
}
|
||
|
||
type ApiHole = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
played: boolean
|
||
score: number | null
|
||
picked_up: boolean
|
||
putts: number | null
|
||
club_off_tee: string | null
|
||
tee_shot_result: TeeShot | null
|
||
approach_result: Approach | null
|
||
chip_count: number | null
|
||
bunker_shot_count: number | null
|
||
penalty_strokes: number | null
|
||
first_putt_distance_bucket: PuttBucket | null
|
||
anyway_strokes: number | null
|
||
strokes_received: number | null
|
||
// Optimistisk versjonssjekk (ADR-057) -- sendes tilbake som
|
||
// expected_version ved neste PATCH, se updateWizardStat.
|
||
version: number
|
||
}
|
||
|
||
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||
// har ÉN score PER SIDE per hull, ikke per spiller -- ingen av de andre
|
||
// detalj-feltene gir entydig mening for en delt ball.
|
||
type ApiSideHole = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
played: boolean
|
||
score: number | null
|
||
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
|
||
// (migrasjon 036) -- helt valgfritt, null = ikke registrert.
|
||
selected_participant_id: string | null
|
||
// Sidens kombinerte playing_handicap sin allokering for DETTE hullet --
|
||
// fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her
|
||
// før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut").
|
||
strokes_received: number | null
|
||
// Optimistisk versjonssjekk (ADR-057) -- se ApiHole.
|
||
version: number
|
||
}
|
||
|
||
function apiHoleToStat(h: ApiHole): HoleStat {
|
||
return {
|
||
played: h.played,
|
||
strokes: h.score,
|
||
pickedUp: h.picked_up,
|
||
putts: h.putts,
|
||
club: h.club_off_tee ?? "",
|
||
teeShot: h.tee_shot_result,
|
||
approach: h.approach_result,
|
||
chip: h.chip_count ?? 0,
|
||
bunker: h.bunker_shot_count ?? 0,
|
||
penalty: h.penalty_strokes ?? 0,
|
||
firstPuttBucket: h.first_putt_distance_bucket,
|
||
anywayStrokes: h.anyway_strokes,
|
||
}
|
||
}
|
||
|
||
function statToPatchBody(s: HoleStat) {
|
||
return {
|
||
played: s.played,
|
||
score: s.strokes,
|
||
picked_up: s.pickedUp,
|
||
putts: s.putts,
|
||
club_off_tee: s.club.trim() === "" ? null : s.club,
|
||
tee_shot_result: s.teeShot,
|
||
approach_result: s.approach,
|
||
chip_count: s.chip,
|
||
bunker_shot_count: s.bunker,
|
||
penalty_strokes: s.penalty,
|
||
first_putt_distance_bucket: s.firstPuttBucket,
|
||
anyway_strokes: s.anywayStrokes,
|
||
}
|
||
}
|
||
|
||
// Alltid det faktiske visningsnavnet -- ALDRI "Deg" (2026-07-27, compliance-
|
||
// pass mot teecup-scorekort-og-entry-spec.md). "Deg" var tidligere en
|
||
// viewer-relativ erstatning for selve navnet, men det produserte en ekte
|
||
// "Deg Deg"-duplikat der navnet OGSÅ sto ved siden av en egen "Deg"-badge
|
||
// (spillerkort, scoringslisten) -- og brøt spesifikasjonens eksplisitte
|
||
// "roster-kontekst → fullt navn"-regel i skjermer uten noen badge i det
|
||
// hele tatt (veiviserens header/kontekst-rad, "Neste: {navn}", fullført-
|
||
// banneret). `isSelf` (se players-konstruksjonen under) er fortsatt
|
||
// viewer-relativ og brukes alene til å vise "Deg"-badgen der det trengs.
|
||
function playerLabel(p: ApiParticipant): string {
|
||
return p.display_name
|
||
}
|
||
|
||
// --- Component -------------------------------------------------------------
|
||
|
||
export function RoundDetail({ roundId }: { roundId: string }) {
|
||
const router = useRouter()
|
||
// Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
|
||
// ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
|
||
// siden. Fungerte derfra fordi det er en navigasjon MELLOM to ulike
|
||
// rute-komponenter (ekte remount, `initialTab` regnes ut på nytt) --
|
||
// men fra selve Score-siden er `/my-rounds/{id}` -> `/my-rounds/{id}
|
||
// ?tab=manage` KUN en søkeparameter-endring på SAMME rute, ingen
|
||
// remount, så `useState(initialTab)` sitt opprinnelige mount-tidspunkt-
|
||
// resultat ble stående uendret -- knappen så ut til å ikke gjøre noe
|
||
// (rapportert av bruker via video 2026-08-03, bekreftet: URL-en endret
|
||
// seg riktig til ?tab=manage, men selve fanen forble "Score"). Fikset
|
||
// med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke
|
||
// bare mount -- ensrettet (kun til "manage"), rører aldri brukerens
|
||
// egen lokale "Score"-valg via pillen etterpå.
|
||
const pathname = usePathname()
|
||
const searchParams = useSearchParams()
|
||
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
|
||
// Bruker rapporterte 2026-08-18: en refresh mens man sto på f.eks. hull 7
|
||
// hoppet alltid tilbake til starthullet (se `currentHole` sin seeding fra
|
||
// `data.start_hole` i loadRound under) -- hull-valget levde KUN i React-
|
||
// state, aldri i URL-en, så en full remount (refresh) mistet det
|
||
// fullstendig. Samme `?param`-i-URL-mønster som `tab` over: les ved mount,
|
||
// hold i sync ved senere endringer via `setHoleAndUrl` (kalt fra ALLE
|
||
// steder som i dag kaller `setCurrentHole` direkte).
|
||
const initialHoleParam = searchParams.get("hole")
|
||
const initialHole = initialHoleParam && /^\d+$/.test(initialHoleParam) ? Number(initialHoleParam) : null
|
||
const [round, setRound] = useState<ApiRound | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
// ADR-057 (2026-08-10): `error` over er en FATAL, hele-siden-erstattende
|
||
// tilstand (se early return under -- "runden finnes ikke" e.l.). En
|
||
// versjonskonflikt på ETT hull (eller en avvist kø-synk) er forbigående
|
||
// og skal IKKE ta over hele skjermen -- brukeren skal bare varsles og
|
||
// fortsette å bruke resten av runden. Egen, dismissbar banner-tilstand
|
||
// i stedet for å gjenbruke `error` her.
|
||
const [conflictNotice, setConflictNotice] = useState<string | null>(null)
|
||
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
|
||
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
|
||
// Flaggturnering: GPS-flaggplanting + runde 2+ (2026-08-14) -- null i
|
||
// flagPlantByParticipant betyr "lastet, men ikke plantet ennå" (skilt fra
|
||
// "ikke lastet", som er fraværet av nøkkelen).
|
||
const [flagPlantByParticipant, setFlagPlantByParticipant] = useState<Record<string, ApiFlagPlant | null>>({})
|
||
const [flagOverflowByParticipant, setFlagOverflowByParticipant] = useState<Record<string, ApiFlagOverflowHole[]>>({})
|
||
// Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
|
||
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
|
||
// for eieren selv (gjester har ingen profil å hente dette fra).
|
||
const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
|
||
// Den innloggede brukerens egen id (ADR-036 fase 3-utvidelsen,
|
||
// 2026-07-26) -- runden kan nå åpnes av en lenket medspiller, ikke bare
|
||
// eieren, så "hvem er 'Deg'" og "hvem har lov til å forvalte runden"
|
||
// avhenger av DENNE, ikke av `is_owner` alene.
|
||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||
// `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
|
||
// er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
|
||
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
|
||
// hull 1 uansett faktisk starthull). Seedes fra `?hole=`-URL-parameteren
|
||
// hvis den finnes (se over) -- ellers forblir den `null` til `loadRound`
|
||
// fyller inn `data.start_hole`, akkurat som før.
|
||
const [currentHole, setCurrentHole] = useState<number | null>(initialHole)
|
||
// Eneste stedet hull-navigasjon skal skje fra -- oppdaterer BÅDE React-
|
||
// state og URL-en (`?hole=`) sammen, slik at en refresh beholder valget.
|
||
// `router.replace` (ikke `push`) -- hull-bytte skal ikke fylle historikken
|
||
// med et tilbake-steg per hull.
|
||
const setHoleAndUrl = useCallback(
|
||
(n: number) => {
|
||
setCurrentHole(n)
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
params.set("hole", String(n))
|
||
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||
},
|
||
[pathname, router, searchParams],
|
||
)
|
||
// ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins-
|
||
// tavlen skjer (hull-registrering, websocket-signal), slik at
|
||
// formatResult-hentingen under vet den skal hente på nytt.
|
||
const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0)
|
||
// Samme "noe endret seg"-signal, brukt av RoundMessages (ADR-044) til å
|
||
// hente kommentarer/bilder på nytt ved WS-signal, uten en egen socket.
|
||
const [messagesRefreshTick, setMessagesRefreshTick] = useState(0)
|
||
// Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/
|
||
// SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant
|
||
// hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad,
|
||
// ikke bare selve statusbanneret. Én henting, delt av begge.
|
||
const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
|
||
const [showAddGuest, setShowAddGuest] = useState(false)
|
||
// Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/
|
||
// spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26,
|
||
// etterspurt av bruker etter å ha sammenlignet direkte med en
|
||
// konkurrentapp -- forrige runde flyttet kun ScoreSoFar, men lot alt det
|
||
// ANDRE administrative innholdet stå igjen øverst på samme side som
|
||
// scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt
|
||
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
||
// fleste trenger under selve spillingen.
|
||
const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
|
||
// Fikser "Administrer"-snarveien fra selve Score-siden (se begrunnelse
|
||
// ved searchParams over) -- reagerer på selve parameter-ENDRINGEN i
|
||
// stedet for kun mount-tidspunktet. Ensrettet med vilje: hopper TIL
|
||
// "manage" når URL-en ber om det, men tvinger aldri tilbake til "score"
|
||
// om parameteren mangler/fjernes (det ville kunnet rive brukeren vekk
|
||
// fra et bevisst lokalt "Score"-valg de allerede har gjort).
|
||
useEffect(() => {
|
||
if (searchParams.get("tab") === "manage") setPageTab("manage")
|
||
}, [searchParams])
|
||
// Fanger opp nettleserens frem/tilbake-navigasjon (URL-en endrer seg uten
|
||
// at `setHoleAndUrl` kalte den) -- speiler `?hole=`-parameteren inn i
|
||
// state igjen. Sammenligner mot gjeldende verdi først for å unngå en
|
||
// uendelig løkke med `setHoleAndUrl` sin egen `router.replace`.
|
||
useEffect(() => {
|
||
const p = searchParams.get("hole")
|
||
const n = p && /^\d+$/.test(p) ? Number(p) : null
|
||
if (n !== null) setCurrentHole((prev) => (prev === n ? prev : n))
|
||
}, [searchParams])
|
||
const [completing, setCompleting] = useState(false)
|
||
const [deleting, setDeleting] = useState(false)
|
||
const [showEditRound, setShowEditRound] = useState(false)
|
||
// Hvilken spillers "Rediger"-panel som er åpent, om noen (2026-07-26 --
|
||
// panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
|
||
// derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
|
||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
|
||
// Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT
|
||
// HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for
|
||
// aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt).
|
||
const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
|
||
// ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer PER
|
||
// SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
|
||
const [wizardSideId, setWizardSideId] = useState<string | null>(null)
|
||
const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
|
||
// Score-fanens hull-fokuserte kortvisning (2026-08-03, V0-eksport zip 27,
|
||
// etterspurt av bruker fordi det brede gridet tok for stor plass i bredden
|
||
// på mobil -- se teecup-scorekort-og-entry-spec.md §1 for gridet dette
|
||
// supplerer). Gridet i seg selv er UENDRET og fortsatt tilgjengelig, bare
|
||
// ikke lenger standardvisningen -- default lukket ("ingenting mistes, kun
|
||
// ett ekstra trykk unna").
|
||
const [showFullScorecard, setShowFullScorecard] = useState(false)
|
||
|
||
// --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------
|
||
// Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx)
|
||
// -- portert hit siden frittstående runder er der man oftest står alene
|
||
// ute på banen uten dekning, og ADR-028s kø tidligere kun var koblet til
|
||
// turnering-scorekortet. Enklere her: PATCH-endepunktene erstatter HELE
|
||
// hull-raden per kall (ikke en append-historie), så en køet skriving kan
|
||
// speiles direkte inn i holesByParticipant/holesBySide -- ingen egen
|
||
// verdi-overlay nødvendig, kun et sett med nøkler for selve "lagret
|
||
// lokalt"-indikatoren i veiviseren.
|
||
const [isOnline, setIsOnline] = useState(true)
|
||
const [pendingCount, setPendingCount] = useState(0)
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [pendingParticipantHoles, setPendingParticipantHoles] = useState<Set<string>>(new Set())
|
||
const [pendingSideHoles, setPendingSideHoles] = useState<Set<string>>(new Set())
|
||
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
|
||
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
|
||
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
|
||
// panelet) er utenfor skjermen (rapportert av bruker 2026-07-25).
|
||
const holePanelRef = useRef<HTMLElement>(null)
|
||
function scrollToHolePanel() {
|
||
holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||
}
|
||
|
||
const loadRound = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
|
||
if (res.status === 403 || res.status === 404) {
|
||
setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
||
return
|
||
}
|
||
if (!res.ok) throw new Error(`round: ${res.status}`)
|
||
const data: ApiRound = await res.json()
|
||
setRound(data)
|
||
setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
|
||
setCurrentHole((prev) => prev ?? data.start_hole)
|
||
// ADR-039 -- sider/deltaker-tildeling kan ha endret seg (SidesPanel
|
||
// kaller denne som onSidesChanged), som igjen kan gjøre FormatResultPanel
|
||
// sitt tidligere "ikke klart"-svar utdatert. Bump ved HVER runde-refetch
|
||
// (billig, og runde-refetch skjer uansett ikke ofte nok til at det er en
|
||
// reell kostnad) i stedet for å spore hver enkelt kallsted som kan
|
||
// påvirke handicap-beregningen.
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
} catch {
|
||
setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
||
}
|
||
}, [roundId])
|
||
|
||
useEffect(() => {
|
||
void loadRound()
|
||
}, [loadRound])
|
||
|
||
// Anker-scroll til kommentarseksjonen (ADR-047) -- leaderboardets nye
|
||
// "Kommentarer"-lenke peker hit, men elementet finnes ikke i DOM-en før
|
||
// `round` er lastet, så nettleserens EGEN anker-scroll (som skjer FØR
|
||
// dette) rekker ikke frem. Trigges på nytt manuelt her når dataen er der.
|
||
useEffect(() => {
|
||
if (!round) return
|
||
if (window.location.hash !== "#kommentarer") return
|
||
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
|
||
}, [round])
|
||
|
||
// Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri
|
||
// lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid
|
||
// (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford
|
||
// (migrasjon 038) trenger ingen egen henting -- ingen sider/matchstatus.
|
||
useEffect(() => {
|
||
if (!round || round.play_format === "stroke" || round.play_format === "stableford") {
|
||
setFormatResult(null)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: ApiFormatResult | null) => {
|
||
if (!cancelled) setFormatResult(data)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round?.play_format, formatResultRefreshTick])
|
||
|
||
// Bingo Bango Bongo (2026-07-30) -- rå bingo/bango/bongo-VALG per hull
|
||
// (ikke poengene, som ligger i formatResult over). Egen henting siden
|
||
// dette er en separat tabell (round_bbb_hole), ikke del av
|
||
// RoundFormatResultOut sin generiske form.
|
||
const [bbbHoles, setBbbHoles] = useState<Record<number, ApiBBBHole>>({})
|
||
useEffect(() => {
|
||
if (!round || round.play_format !== "bbb") {
|
||
setBbbHoles({})
|
||
return
|
||
}
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/bbb`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: ApiBBBHole[]) => {
|
||
if (!cancelled) setBbbHoles(Object.fromEntries(data.map((h) => [h.hole_number, h])))
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round?.play_format, formatResultRefreshTick])
|
||
|
||
async function patchBbbHole(holeNumber: number, body: Record<string, string | null>) {
|
||
const res = await fetch(`/rounds/${roundId}/bbb/${holeNumber}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) return
|
||
const updated: ApiBBBHole = await res.json()
|
||
setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated }))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
// Flaggturnering (2026-08-14) -- laster flagg-status + runde 2-hull for
|
||
// ALLE deltakere (samme "ikke bare aktiv spiller"-begrunnelse som
|
||
// holesByParticipant under), kun for play_format="flag".
|
||
useEffect(() => {
|
||
if (!round || round.play_format !== "flag") {
|
||
setFlagPlantByParticipant({})
|
||
setFlagOverflowByParticipant({})
|
||
return
|
||
}
|
||
let cancelled = false
|
||
async function loadAll() {
|
||
for (const p of round!.participants) {
|
||
const [plantRes, overflowRes] = await Promise.all([
|
||
fetch(`/rounds/${roundId}/participants/${p.id}/flag-plant`, { credentials: "include" }),
|
||
fetch(`/rounds/${roundId}/participants/${p.id}/flag-overflow`, { credentials: "include" }),
|
||
])
|
||
if (cancelled) return
|
||
const plant: ApiFlagPlant | null = plantRes.ok ? await plantRes.json() : null
|
||
const overflow: ApiFlagOverflowHole[] = overflowRes.ok ? await overflowRes.json() : []
|
||
setFlagPlantByParticipant((prev) => ({ ...prev, [p.id]: plant }))
|
||
setFlagOverflowByParticipant((prev) => ({ ...prev, [p.id]: overflow }))
|
||
}
|
||
}
|
||
void loadAll()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round?.play_format, round?.participants.length, formatResultRefreshTick])
|
||
|
||
async function plantFlag(
|
||
participantId: string,
|
||
body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null },
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å plante flagget. Prøv igjen." }
|
||
}
|
||
const updated: ApiFlagPlant = await res.json()
|
||
setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: updated }))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
return { ok: true }
|
||
}
|
||
|
||
async function unplantFlag(participantId: string): Promise<void> {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
})
|
||
if (!res.ok) return
|
||
setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: null }))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
async function updateFlagOverflowHole(
|
||
participantId: string, lap: number, holeNumber: number, score: number,
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(
|
||
`/rounds/${roundId}/participants/${participantId}/flag-overflow/${lap}/holes/${holeNumber}`,
|
||
{
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ score }),
|
||
},
|
||
)
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å registrere score. Prøv igjen." }
|
||
}
|
||
const updated: ApiFlagOverflowHole = await res.json()
|
||
setFlagOverflowByParticipant((prev) => ({
|
||
...prev,
|
||
[participantId]: [
|
||
...(prev[participantId] ?? []).filter((h) => !(h.lap === updated.lap && h.hole_number === updated.hole_number)),
|
||
updated,
|
||
],
|
||
}))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
return { ok: true }
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch("/auth/me", { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: { id: string; bag_clubs: string[] } | null) => {
|
||
if (!cancelled && data) {
|
||
setOwnBagClubs(data.bag_clubs)
|
||
setViewerId(data.id)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
const loadHoles = useCallback(
|
||
async (participantId: string) => {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
|
||
if (!res.ok) return
|
||
const data: ApiHole[] = await res.json()
|
||
setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
|
||
},
|
||
[roundId],
|
||
)
|
||
|
||
// Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) --
|
||
// den nye scoringslisten viser akkumulert score-så-langt for HVER spiller
|
||
// samtidig, ikke kun den ene som tilfeldigvis er "aktiv".
|
||
useEffect(() => {
|
||
if (!round) return
|
||
for (const p of round.participants) {
|
||
if (!holesByParticipant[p.id]) {
|
||
void loadHoles(p.id)
|
||
}
|
||
}
|
||
}, [round, holesByParticipant, loadHoles])
|
||
|
||
// ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer per
|
||
// SIDE, ikke per deltaker (se holesBySide over).
|
||
const loadSideHoles = useCallback(
|
||
async (sideId: string) => {
|
||
const res = await fetch(`/rounds/${roundId}/sides/${sideId}/holes`, { credentials: "include" })
|
||
if (!res.ok) return
|
||
const data: ApiSideHole[] = await res.json()
|
||
setHolesBySide((prev) => ({ ...prev, [sideId]: data }))
|
||
},
|
||
[roundId],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!round) return
|
||
for (const s of round.sides) {
|
||
if (!holesBySide[s.id]) {
|
||
void loadSideHoles(s.id)
|
||
}
|
||
}
|
||
}, [round, holesBySide, loadSideHoles])
|
||
|
||
// --- Offline scoreregistrering, forts. -------------------------------
|
||
// Køer en PATCH i IndexedDB og speiler den samme kroppen optimistisk inn
|
||
// i lokal state -- body sine felt matcher ApiHole/ApiSideHole sine
|
||
// navn 1:1 (se statToPatchBody), så en enkel spread er nok, ingen egen
|
||
// oversettelse. Serveren får siste ord når synk faktisk skjer (bl.a.
|
||
// strokes_received kan derfor være utdatert helt til da).
|
||
async function queueParticipantHoleWrite(
|
||
participantId: string,
|
||
holeNumber: number,
|
||
body: ReturnType<typeof statToPatchBody>,
|
||
) {
|
||
const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`
|
||
// expected_version = versjonen vi SIST kjenner til lokalt (ADR-057) --
|
||
// flushQueue kjeder den videre selv for flere køede skrivinger til
|
||
// samme hull i én batch, se offline-queue.ts.
|
||
const currentVersion = holesByParticipant[participantId]?.find((h) => h.hole_number === holeNumber)?.version
|
||
const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
|
||
await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
|
||
setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`))
|
||
setPendingCount((c) => c + 1)
|
||
setHolesByParticipant((prev) => ({
|
||
...prev,
|
||
[participantId]: (prev[participantId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||
}))
|
||
}
|
||
|
||
async function queueSideHoleWrite(
|
||
sideId: string,
|
||
holeNumber: number,
|
||
body: { played: boolean; score: number | null; selected_participant_id: string | null },
|
||
) {
|
||
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
|
||
const currentVersion = holesBySide[sideId]?.find((h) => h.hole_number === holeNumber)?.version
|
||
const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
|
||
await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
|
||
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
|
||
setPendingCount((c) => c + 1)
|
||
setHolesBySide((prev) => ({
|
||
...prev,
|
||
[sideId]: (prev[sideId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||
}))
|
||
}
|
||
|
||
// Sender køede skrivinger og henter deltakerens/sidens hull på nytt fra
|
||
// serveren for hver som faktisk fikk noe synkronisert -- reconciles bl.a.
|
||
// strokes_received, som den optimistiske speilingen over ikke kan regne
|
||
// ut selv. Matcher URL-mønsteret for å vite hvilken type/id det gjaldt
|
||
// (samme kø brukes til begge endepunktstypene).
|
||
async function flushPending() {
|
||
if (syncing) return
|
||
setSyncing(true)
|
||
try {
|
||
const outcomes = await flushQueue(roundId)
|
||
if (outcomes.length === 0) return
|
||
const syncedParticipants = new Set<string>()
|
||
const syncedSides = new Set<string>()
|
||
for (const o of outcomes) {
|
||
const participantMatch = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/)
|
||
if (participantMatch) {
|
||
setPendingParticipantHoles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(`${participantMatch[1]}:${participantMatch[2]}`)
|
||
return next
|
||
})
|
||
syncedParticipants.add(participantMatch[1])
|
||
continue
|
||
}
|
||
const sideMatch = o.entry.url.match(/\/sides\/([^/]+)\/holes\/(\d+)$/)
|
||
if (sideMatch) {
|
||
setPendingSideHoles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(`${sideMatch[1]}:${sideMatch[2]}`)
|
||
return next
|
||
})
|
||
syncedSides.add(sideMatch[1])
|
||
}
|
||
}
|
||
const failed = outcomes.filter((o) => !o.ok)
|
||
if (failed.length > 0) {
|
||
// ADR-057: en avvist kø-synk (f.eks. en versjonskonflikt, se under)
|
||
// er forbigående -- IKKE `setError`, som ville tatt over hele
|
||
// siden. Hullene det gjaldt er allerede hentet på nytt over.
|
||
setConflictNotice(
|
||
`${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
|
||
)
|
||
}
|
||
setPendingCount(await queueCount(roundId))
|
||
for (const id of syncedParticipants) void loadHoles(id)
|
||
for (const id of syncedSides) void loadSideHoles(id)
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
} finally {
|
||
setSyncing(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setIsOnline(navigator.onLine)
|
||
function handleOnline() {
|
||
setIsOnline(true)
|
||
void flushPending()
|
||
}
|
||
function handleOffline() {
|
||
setIsOnline(false)
|
||
}
|
||
window.addEventListener("online", handleOnline)
|
||
window.addEventListener("offline", handleOffline)
|
||
return () => {
|
||
window.removeEventListener("online", handleOnline)
|
||
window.removeEventListener("offline", handleOffline)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roundId])
|
||
|
||
// Sjekk for allerede køede skrivinger fra en tidligere økt (f.eks. siden
|
||
// ble lukket mens offline) -- samme engangs-sjekk som session-scorecard.tsx.
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
queueCount(roundId)
|
||
.then((count) => {
|
||
if (cancelled) return
|
||
setPendingCount(count)
|
||
if (count > 0 && navigator.onLine) void flushPending()
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roundId])
|
||
|
||
// Alltid gjeldende deltaker-/side-id-er tilgjengelig INNI WebSocket-
|
||
// handleren under, uten å måtte koble socket-en til/fra ved hver ny
|
||
// runde-lasting (en vanlig closure over `round` i effektens
|
||
// dependency-array ville krevd det).
|
||
const participantIdsRef = useRef<string[]>([])
|
||
const sideIdsRef = useRef<string[]>([])
|
||
useEffect(() => {
|
||
participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
|
||
sideIdsRef.current = round?.sides.map((s) => s.id) ?? []
|
||
}, [round])
|
||
|
||
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
|
||
// signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST-
|
||
// kallene den uansett gjør ved førstegangslasting. Henter nå ALLE
|
||
// deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten
|
||
// viser alles akkumulerte score-så-langt samtidig.
|
||
useEffect(() => {
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||
socket.onmessage = () => {
|
||
void loadRound()
|
||
for (const id of participantIdsRef.current) {
|
||
void loadHoles(id)
|
||
}
|
||
for (const id of sideIdsRef.current) {
|
||
void loadSideHoles(id)
|
||
}
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
setMessagesRefreshTick((n) => n + 1)
|
||
}
|
||
return () => socket.close()
|
||
}, [roundId, loadRound, loadHoles, loadSideHoles])
|
||
|
||
const completed = round?.completed_at != null
|
||
const readOnly = completed
|
||
|
||
const players: Player[] =
|
||
round?.participants.map((p) => ({
|
||
id: p.id,
|
||
name: playerLabel(p),
|
||
userId: p.user_id,
|
||
guestFirstName: p.guest_first_name,
|
||
guestLastName: p.guest_last_name,
|
||
guestEmail: p.guest_email,
|
||
isOwner: p.is_owner,
|
||
gender: apiGenderToUi(p.gender),
|
||
hcp: p.handicap_index_snapshot,
|
||
courseHandicap: p.course_handicap_snapshot,
|
||
teeName: p.tee_name_snapshot,
|
||
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
||
countsForHandicap: p.counts_for_handicap,
|
||
scoreDifferential: p.score_differential,
|
||
statLevel: p.stat_level,
|
||
excludeFromHandicap: p.exclude_from_handicap,
|
||
roundSideId: p.round_side_id,
|
||
playingHandicap: p.playing_handicap,
|
||
lineupOrder: p.lineup_order,
|
||
completed: p.completed,
|
||
})) ?? []
|
||
|
||
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
||
// medspillere) -- en lenket medspiller kan se runden og registrere
|
||
// score for hele flighten, men ikke dette (ADR-036 fase 3-utvidelsen).
|
||
const isOwnerViewer = viewerId !== null && (round?.participants.some((p) => p.is_owner && p.user_id === viewerId) ?? false)
|
||
|
||
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
|
||
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
|
||
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
|
||
// loadRound() har satt currentHole fra round.start_hole.
|
||
const activeHole = currentHole ?? round?.start_hole ?? 1
|
||
// Par/index for aktivt hull -- samme for ALLE deltakere/sider (samme
|
||
// bane), hentet fra HVILKEN SOM HELST kilde som allerede har lastet inn
|
||
// hullene sine (2026-08-05, erstatter en tidligere versjon som kun så på
|
||
// activePlayerId sin egen liste -- brøt permanent for scramble_solo sine
|
||
// lagspillere, som ALDRI får egne round_participant_id-keyede hull-rader
|
||
// siden laget deler ball via round_side_id. En aktiv spiller på laget
|
||
// ga derfor en evig tom `holes`-liste og en evig spinner). Samme mønster
|
||
// som delt-ball-formatenes side-baserte oppslag alltid har brukt, nå
|
||
// generalisert til å også dekke deltaker-baserte kilder.
|
||
const hole: Hole | null = (() => {
|
||
for (const list of Object.values(holesByParticipant)) {
|
||
const h = list.find((x) => x.hole_number === activeHole)
|
||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||
}
|
||
for (const s of round?.sides ?? []) {
|
||
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
|
||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||
}
|
||
return null
|
||
})()
|
||
|
||
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
|
||
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
|
||
// planlegger (holes_planned) -- en 9-hulls runde 10-18 skal aldri vise
|
||
// hull 1-9 i det hele tatt, og "Forrige" fra hull 10 skal gå til hull
|
||
// 18 (siste i settet), ikke hull 9 (funnet 2026-07-24: brukte tidligere
|
||
// alltid 18, uansett holes_planned).
|
||
const holeOrder = round
|
||
? Array.from({ length: round.holes_planned }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1)
|
||
: []
|
||
|
||
// Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
|
||
// nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
|
||
// "Så langt i runden"-oversikt lenger ned som vises).
|
||
const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null
|
||
const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||
const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat()
|
||
|
||
async function updateWizardStat(patch: Partial<HoleStat>) {
|
||
if (readOnly || !wizardPlayerId) return
|
||
const merged: HoleStat = { ...wizardStat, ...patch }
|
||
const body = statToPatchBody(merged)
|
||
if (!navigator.onLine) {
|
||
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||
return
|
||
}
|
||
// Optimistisk versjonssjekk (ADR-057) -- sendes med selv på den
|
||
// direkte (online) skrive-veien, ikke bare via offline-køen.
|
||
const bodyWithVersion = { ...body, expected_version: wizardApiHole?.version ?? null }
|
||
let res: Response
|
||
try {
|
||
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(bodyWithVersion),
|
||
})
|
||
} catch {
|
||
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
||
// for å tape registreringen, se ADR-028.
|
||
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||
return
|
||
}
|
||
if (!res.ok) {
|
||
// 409 = en annen enhet skrev til akkurat dette hullet først (ADR-057).
|
||
// Vis det tydelig og hent inn den andres versjon i stedet for å la
|
||
// brukeren tro sin egen, nå avviste, endring gjaldt.
|
||
if (res.status === 409) {
|
||
setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
|
||
void loadHoles(wizardPlayerId)
|
||
}
|
||
return
|
||
}
|
||
const updated: ApiHole = await res.json()
|
||
setHolesByParticipant((prev) => ({
|
||
...prev,
|
||
[wizardPlayerId]: (prev[wizardPlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
|
||
}))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
function goPrev() {
|
||
const i = holeOrder.indexOf(activeHole)
|
||
setHoleAndUrl(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
|
||
scrollToHolePanel()
|
||
}
|
||
function goNext() {
|
||
const i = holeOrder.indexOf(activeHole)
|
||
setHoleAndUrl(holeOrder[(i + 1) % holeOrder.length])
|
||
scrollToHolePanel()
|
||
}
|
||
|
||
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
|
||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
|
||
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
|
||
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
|
||
// mening for en delt ball).
|
||
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
|
||
// Scramble mot enkeltspiller (2026-08-05) -- KUN lagets side, aldri
|
||
// individualspillerens (den fortsetter å bruke vanlig ScoringWizard/
|
||
// wizardPlayerId). Null for alle andre formater.
|
||
const scrambleSoloTeamSideId =
|
||
round && SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||
? round.sides.find((s) => s.side_role === "team")?.id ?? null
|
||
: null
|
||
const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
|
||
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||
|
||
// Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
|
||
// "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
|
||
// enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
|
||
// (samme lagscore for alle, keyet på round_side_id), mens
|
||
// individualspilleren fortsatt åpner den vanlige, per-deltaker
|
||
// ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
|
||
// som før.
|
||
function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
|
||
setHoleAndUrl(holeNumber)
|
||
const player = players.find((p) => p.id === playerId)
|
||
if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
|
||
setWizardSideId(scrambleSoloTeamSideId)
|
||
return
|
||
}
|
||
setActivePlayerId(playerId)
|
||
setWizardPlayerId(playerId)
|
||
}
|
||
|
||
// Tilbake til en tidligere spiller fra INNI veiviseren (ADR-077,
|
||
// 2026-08-15) -- samme mål-oppløsning som openPlayerOrTeamSideEntry over,
|
||
// men rører IKKE currentHole (vi er allerede på riktig hull, kalt fra
|
||
// kontekst-raden i selve veiviseren).
|
||
function switchWizardPlayer(playerId: string) {
|
||
const target = players.find((p) => p.id === playerId)
|
||
if (scrambleSoloTeamSideId && target?.roundSideId === scrambleSoloTeamSideId) {
|
||
setWizardPlayerId(null)
|
||
setWizardSideId(scrambleSoloTeamSideId)
|
||
return
|
||
}
|
||
setActivePlayerId(playerId)
|
||
setWizardPlayerId(playerId)
|
||
}
|
||
|
||
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
|
||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
|
||
// Hopper over lagets spillere for scramble_solo (2026-08-05) -- de scorer
|
||
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
|
||
// de skal aldri kunne bli et "neste"-mål her heller.
|
||
function advanceWizardPlayer() {
|
||
// Fullførte spillere (ADR-065) hopper over i samlebåndet -- de skal
|
||
// ikke bli "neste mål" for registrering, akkurat som scramble_solos
|
||
// lag-spillere allerede hoppes over.
|
||
const chain = players.filter((p) => (!scrambleSoloTeamSideId || p.roundSideId !== scrambleSoloTeamSideId) && !p.completed)
|
||
const idx = chain.findIndex((p) => p.id === wizardPlayerId)
|
||
const isLast = idx === -1 || idx === chain.length - 1
|
||
if (!isLast) {
|
||
const next = chain[idx + 1]
|
||
setActivePlayerId(next.id)
|
||
setWizardPlayerId(next.id)
|
||
} else {
|
||
setWizardPlayerId(null)
|
||
setActivePlayerId(chain[0]?.id ?? null)
|
||
goNext()
|
||
}
|
||
}
|
||
|
||
// Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE
|
||
// deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
|
||
// vis en tydelig CTA på Score-fanen i stedet for å stole på at brukeren
|
||
// selv husker den tonet-ned knappen i "Spillere og runde"-fanen. Samme
|
||
// `setup_complete`-gate som selve knappen (unngår å reklamere for en
|
||
// handling som uansett ville vært deaktivert).
|
||
const allHolesEnteredForEveryone =
|
||
!completed &&
|
||
round !== null &&
|
||
round.setup_complete &&
|
||
holeOrder.length > 0 &&
|
||
(isSharedBall
|
||
? round.sides.length >= 2 &&
|
||
round.sides.every((s) => {
|
||
const list = holesBySide[s.id]
|
||
return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
|
||
})
|
||
: players.length > 0 &&
|
||
players.every((p) => {
|
||
// ADR-065 -- en individuelt avsluttet spiller teller som "ferdig"
|
||
// for CTA-formål selv om ikke alle hull i holeOrder faktisk er ført.
|
||
if (p.completed) return true
|
||
const list = holesByParticipant[p.id]
|
||
return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
|
||
}))
|
||
|
||
// Begge feltene (score OG selected_participant_id) sendes ALLTID sammen
|
||
// (samme "full overwrite hvert kall"-kontrakt som played/score alltid har
|
||
// hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede
|
||
// registrert utslags-valg (migrasjon 036), siden backend ikke gjør en
|
||
// delvis PATCH her.
|
||
async function patchSideHole(score: number | null, selectedParticipantId: string | null) {
|
||
if (readOnly || !wizardSideId) return
|
||
const body = { played: true, score, selected_participant_id: selectedParticipantId }
|
||
if (!navigator.onLine) {
|
||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||
return
|
||
}
|
||
// Optimistisk versjonssjekk (ADR-057) -- se updateWizardStat.
|
||
const bodyWithVersion = { ...body, expected_version: wizardSideHole?.version ?? null }
|
||
let res: Response
|
||
try {
|
||
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(bodyWithVersion),
|
||
})
|
||
} catch {
|
||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||
return
|
||
}
|
||
if (!res.ok) {
|
||
if (res.status === 409) {
|
||
setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
|
||
void loadSideHoles(wizardSideId)
|
||
}
|
||
return
|
||
}
|
||
const updated: ApiSideHole = await res.json()
|
||
setHolesBySide((prev) => ({
|
||
...prev,
|
||
[wizardSideId]: (prev[wizardSideId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
|
||
}))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
function updateWizardSideScore(score: number | null) {
|
||
void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null)
|
||
}
|
||
|
||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt
|
||
// valgfritt, egen handling fra selve slagregistreringen slik at man kan
|
||
// sette/endre valget uten å røre et allerede registrert slagtall.
|
||
function updateWizardSideSelection(participantId: string | null) {
|
||
void patchSideHole(wizardSideHole?.score ?? null, participantId)
|
||
}
|
||
|
||
function advanceWizardSide() {
|
||
if (!round) return
|
||
// Scramble mot enkeltspiller (2026-08-05): kun ÉN side er noensinne
|
||
// side-veiviser-styrt (laget) -- individualspilleren har sin egen,
|
||
// separate ScoringWizard-kjede (advanceWizardPlayer). Aldri "hopp til
|
||
// neste side" her, i motsetning til ekte delt-ball-formater der begge
|
||
// sider deler samme veiviser-kjede.
|
||
if (scrambleSoloTeamSideId) {
|
||
setWizardSideId(null)
|
||
goNext()
|
||
return
|
||
}
|
||
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
|
||
const isLast = idx === -1 || idx === round.sides.length - 1
|
||
if (!isLast) {
|
||
setWizardSideId(round.sides[idx + 1].id)
|
||
} else {
|
||
setWizardSideId(null)
|
||
goNext()
|
||
}
|
||
}
|
||
|
||
async function addGuest(
|
||
firstName: string,
|
||
lastName: string,
|
||
email: string,
|
||
gender: Gender,
|
||
hcp: number | null,
|
||
statLevel: StatLevel,
|
||
) {
|
||
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
guest_first_name: firstName,
|
||
guest_last_name: lastName.trim() === "" ? null : lastName,
|
||
guest_email: email.trim() === "" ? null : email.trim(),
|
||
gender: uiGenderToApi(gender),
|
||
handicap_index: hcp,
|
||
stat_level: statLevel,
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
|
||
return
|
||
}
|
||
setShowAddGuest(false)
|
||
const created: ApiParticipant = await res.json()
|
||
await loadRound()
|
||
setActivePlayerId(created.id)
|
||
}
|
||
|
||
async function addSearchedParticipant(userId: string, statLevel: StatLevel): Promise<string | null> {
|
||
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ user_id: userId, stat_level: statLevel }),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
return body?.detail?.message ?? "Klarte ikke å legge til spilleren."
|
||
}
|
||
setShowAddGuest(false)
|
||
const created: ApiParticipant = await res.json()
|
||
await loadRound()
|
||
setActivePlayerId(created.id)
|
||
return null
|
||
}
|
||
|
||
async function removeGuest(id: string) {
|
||
if (!confirm("Fjerne denne spilleren fra runden?")) return
|
||
const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) return
|
||
if (activePlayerId === id) setActivePlayerId(round?.participants.find((p) => p.is_owner)?.id ?? null)
|
||
await loadRound()
|
||
}
|
||
|
||
// Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) for en
|
||
// enkelt deltaker (2026-07-26) -- gjelder KUN denne
|
||
// runden, rører aldri spillerens egen profil. Bygget for å dekke at en
|
||
// medspiller kan spille fra et annet utslag enn eieren, og at en foreslått
|
||
// HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen.
|
||
async function patchParticipant(
|
||
participantId: string,
|
||
body: Record<string, unknown>,
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere spilleren. Prøv igjen." }
|
||
}
|
||
const updated: ApiParticipant = await res.json()
|
||
setRound((prev) =>
|
||
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
|
||
)
|
||
return { ok: true }
|
||
}
|
||
|
||
// ADR-065 -- fungerer UANSETT fullføringsstatus, også lenge etter at
|
||
// runden (eller kun denne deltakeren) er fullført. Muterer ingen
|
||
// databasetilstand, kun les+send -- derfor ingen lokal round-oppdatering
|
||
// her, ulikt patchParticipant.
|
||
async function sendScorecard(participantId: string): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/send-scorecard`, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
})
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
const message =
|
||
errBody?.detail?.code === "NO_EMAIL_ADDRESS"
|
||
? "Denne spilleren har ingen registrert e-postadresse."
|
||
: (errBody?.detail?.message ?? "Klarte ikke å sende scorekortet. Prøv igjen.")
|
||
return { ok: false, message }
|
||
}
|
||
return { ok: true }
|
||
}
|
||
|
||
async function finishRound() {
|
||
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
|
||
setCompleting(true)
|
||
try {
|
||
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setRound(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å fullføre runden. Prøv igjen.")
|
||
} finally {
|
||
setCompleting(false)
|
||
}
|
||
}
|
||
|
||
async function deleteRound() {
|
||
if (!confirm("Slette denne runden permanent? Dette kan ikke angres, og gjelder for alle spillerne i runden.")) return
|
||
setDeleting(true)
|
||
try {
|
||
const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
router.replace("/my-rounds")
|
||
} catch {
|
||
setError("Klarte ikke å slette runden. Prøv igjen.")
|
||
setDeleting(false)
|
||
}
|
||
}
|
||
|
||
// Retter opp feil bane/utslag eller feil antall hull underveis i runden
|
||
// (2026-07-24) -- rører ALDRI allerede registrerte slag/putter/etc.,
|
||
// kun rating-grunnlaget. Backend avviser tydelig (400) hvis en deltaker
|
||
// ville mistet HCP-sporing, uten å skrive noe.
|
||
async function patchRound(body: Record<string, unknown>): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere runden. Prøv igjen." }
|
||
}
|
||
const updated: ApiRound = await res.json()
|
||
setRound(updated)
|
||
// Par/stroke-indeks kan ha endret seg for ALLE deltakere -- hent hull-
|
||
// dataen på nytt for alle vi allerede har lastet, ikke bare aktiv spiller.
|
||
const participantIds = Object.keys(holesByParticipant)
|
||
for (const pid of participantIds) {
|
||
await loadHoles(pid)
|
||
}
|
||
return { ok: true }
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||
<p className="text-base font-medium text-destructive">{error}</p>
|
||
<Link
|
||
href="/my-rounds"
|
||
className="flex min-h-11 items-center text-base font-semibold text-primary underline underline-offset-2"
|
||
>
|
||
Tilbake til egne runder
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!round || !activePlayer) {
|
||
return (
|
||
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<RoundPageShell roundId={roundId} activeTab="score">
|
||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||
{conflictNotice && (
|
||
<div
|
||
role="alert"
|
||
className="mb-4 flex items-start justify-between gap-3 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3"
|
||
>
|
||
<p className="text-base font-medium text-destructive">{conflictNotice}</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setConflictNotice(null)}
|
||
className="shrink-0 text-sm font-semibold text-destructive underline underline-offset-2"
|
||
>
|
||
Skjul
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* To faner: "Score" (default, KUN hull-navigasjon + registrering --
|
||
det eneste de fleste trenger midt i en runde) og "Spillere og
|
||
runde" (alt administrativt, samlet ett sted i stedet for å ligge
|
||
i veien øverst på scoringssiden). */}
|
||
<div className="mb-5 inline-flex gap-1 rounded-full bg-muted p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPageTab("score")}
|
||
aria-pressed={pageTab === "score"}
|
||
className={cn(
|
||
"min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
|
||
pageTab === "score" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Score
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPageTab("manage")}
|
||
aria-pressed={pageTab === "manage"}
|
||
className={cn(
|
||
"min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
|
||
pageTab === "manage" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Spillere og runde
|
||
</button>
|
||
</div>
|
||
|
||
{/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett
|
||
fane, siden offline-tilstanden er global for hele siden. */}
|
||
{!isOnline && (
|
||
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
||
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
|
||
</div>
|
||
)}
|
||
{pendingCount > 0 && (
|
||
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||
<span>
|
||
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
|
||
</span>
|
||
{isOnline && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void flushPending()}
|
||
disabled={syncing}
|
||
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||
>
|
||
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
|
||
Synkroniser nå
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{pageTab === "manage" && (
|
||
<>
|
||
{/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
|
||
runder med flere enn én deltaker (komponenten returnerer selv
|
||
null ellers), lenke til hele leaderboardet. */}
|
||
<RoundLeaderboardMini roundId={roundId} />
|
||
|
||
{/* Løpende matchstatus (to-sidede formater) eller skins-tavle
|
||
(ADR-039) -- avledet ved lesing, aldri lagret. */}
|
||
<FormatResultPanel round={round} players={players} result={formatResult} />
|
||
|
||
{/* Flaggturnering: kartoversikt (migrasjon 071, "Del B",
|
||
ADR-067) -- kun for eier/medspiller (samme tilgang som
|
||
resten av denne siden), av/på-bryteren styrer om ALLE
|
||
flagg eller kun spørrerens eget vises (se komponenten). */}
|
||
{round.play_format === "flag" && (
|
||
<FlagMapSection roundId={roundId} formatResultRefreshTick={formatResultRefreshTick} />
|
||
)}
|
||
|
||
{/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer
|
||
(front9/back9/overall) av det samme to-sidede formatet,
|
||
kun for 18-hulls runder. Ren tilleggsvisning, ikke et eget
|
||
play_format. */}
|
||
{TWO_SIDED_FORMATS.has(round.play_format) && round.holes_planned === 18 && (
|
||
<NassauPanel roundId={roundId} formatResultRefreshTick={formatResultRefreshTick} />
|
||
)}
|
||
|
||
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
|
||
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
|
||
Eieren tar ansvar for å sette opp ALLE flightene i én
|
||
handling ("+ Legg til en flight til" -> gjenbruker hele
|
||
opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
|
||
<FlightGroupPanel roundId={roundId} round={round} isOwnerViewer={isOwnerViewer} />
|
||
|
||
<div className="mb-5 flex flex-col gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEditRound((v) => !v)}
|
||
aria-expanded={showEditRound}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<Settings2 aria-hidden="true" className="size-4" />
|
||
Rediger runde
|
||
</button>
|
||
)}
|
||
|
||
{!completed && (
|
||
<button
|
||
type="button"
|
||
disabled={completing || !round.setup_complete}
|
||
onClick={finishRound}
|
||
title={!round.setup_complete ? round.setup_message ?? undefined : undefined}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
|
||
>
|
||
<Check aria-hidden="true" className="size-4" />
|
||
{completing ? "Fullfører…" : "Fullfør runde"}
|
||
</button>
|
||
)}
|
||
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
disabled={deleting}
|
||
onClick={deleteRound}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
{deleting ? "Sletter…" : "Slett runde"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{showEditRound && isOwnerViewer && (
|
||
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Sider (ADR-039 Beslutning A) -- kun for formater som faktisk
|
||
bruker sider (match/fourball/foursome/greensome/scramble).
|
||
Opprett/slett sider + fordel deltakerne. */}
|
||
{TWO_SIDED_FORMATS.has(round.play_format) && (
|
||
<SidesPanel
|
||
roundId={roundId}
|
||
round={round}
|
||
players={players}
|
||
canManage={isOwnerViewer && !readOnly}
|
||
onSidesChanged={loadRound}
|
||
onPatchParticipant={patchParticipant}
|
||
/>
|
||
)}
|
||
|
||
{!round.setup_complete && !completed && (
|
||
<p role="status" className="mb-5 rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||
{round.setup_message ?? "Oppsettet er ikke komplett ennå."}
|
||
</p>
|
||
)}
|
||
|
||
{/* Spillerliste (2026-07-26, erstatter tidligere horisontal
|
||
fane-rad) -- vertikal, ett kort per spiller. Utslag/HCP/
|
||
statistikknivå + "Rediger" ligger nå INNI hvert kort (samme
|
||
kort er også selve velg-aktiv-spiller-trykkflaten), i stedet
|
||
for en egen rad under listen som kun gjaldt aktiv spiller. */}
|
||
<PlayerList
|
||
roundId={roundId}
|
||
players={players}
|
||
sides={round.sides}
|
||
holesByParticipant={holesByParticipant}
|
||
activePlayerId={activePlayer.id}
|
||
onSelect={setActivePlayerId}
|
||
onRemove={removeGuest}
|
||
onAdd={() => setShowAddGuest((v) => !v)}
|
||
addOpen={showAddGuest}
|
||
readOnly={readOnly}
|
||
canManage={isOwnerViewer}
|
||
editingPlayerId={editingPlayerId}
|
||
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
|
||
onPatchParticipant={patchParticipant}
|
||
onSendScorecard={sendScorecard}
|
||
onAddGuest={addGuest}
|
||
onAddSearched={addSearchedParticipant}
|
||
/>
|
||
|
||
{/* Flyttet HIT (2026-07-26, samme redesign-runde) -- lå tidligere
|
||
på selve scoringssiden (først øverst, deretter lenger ned),
|
||
men er detaljert stats-innsyn (fairway/innspill/kølle-
|
||
fordeling for VALGT spiller), ikke selve registrerings-
|
||
oppgaven. Hver spillers rad i scoringslisten viser allerede
|
||
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
||
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
||
score-registreringen. */}
|
||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
||
</>
|
||
)}
|
||
|
||
{pageTab === "score" && (
|
||
<>
|
||
{/* Matchstatus/skins-tavle øverst på Score-fanen også (2026-07-29,
|
||
etterspurt av bruker -- fantes fra før KUN under "Spillere og
|
||
runde", lett å overse siden scoring naturlig skjer her).
|
||
Samme komponent, ikke duplisert logikk. */}
|
||
<FormatResultPanel round={round} players={players} result={formatResult} />
|
||
|
||
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon
|
||
(ikke GPS/live-sporet), separat fra selve slag-/putt-
|
||
registreringen (som fortsatt skjer via vanlig ScoringWizard,
|
||
for stat_level/GIR/HCP-formål -- BBB-poengene er UAVHENGIGE
|
||
av gross_strokes). */}
|
||
{round.play_format === "bbb" && !readOnly && (
|
||
<BBBHolePicker
|
||
players={players}
|
||
holeNumber={activeHole}
|
||
selection={bbbHoles[activeHole] ?? null}
|
||
onSet={patchBbbHole}
|
||
/>
|
||
)}
|
||
|
||
{/* Money Ball (2026-07-30) -- rekkefølgen (0-3) må være tildelt for
|
||
alle fire FØR scoring/fullføring gir mening (avgjør hvem som er
|
||
"money ball" på hvilket hull). Vises kun til rekkefølgen er
|
||
komplett, deretter automatisk skjult. */}
|
||
{round.play_format === "money_ball" && isOwnerViewer && !readOnly && (
|
||
<MoneyBallLineupPanel players={players} onPatchParticipant={patchParticipant} />
|
||
)}
|
||
|
||
{/* Auto-prompt "Fullfør runde" (2026-07-30) -- kun synlig når alle
|
||
hull faktisk er ført for alle, forsvinner selv idet runden er
|
||
fullført (completed flipper til true). */}
|
||
{allHolesEnteredForEveryone && <AllHolesEnteredBanner completing={completing} onComplete={finishRound} />}
|
||
|
||
{/* Completed summary banner */}
|
||
{completed && (
|
||
<CompletedBanner
|
||
players={players}
|
||
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
|
||
roundId={roundId}
|
||
/>
|
||
)}
|
||
|
||
{/* ADR-039 Beslutning C -- delt-ball-formater scorer PER SIDE, ikke
|
||
per spiller. Vises kun når begge sider faktisk er opprettet
|
||
(SidesPanel i "Spillere og runde" setter dette opp). */}
|
||
{isSharedBall && round.sides.length < 2 ? (
|
||
<p className="mt-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
Opprett begge sidene under "Spillere og runde" før du kan registrere score.
|
||
</p>
|
||
) : isSharedBall ? (
|
||
round.sides.some((s) => !holesBySide[s.id]) ? (
|
||
<div className="mt-5 flex justify-center py-8">
|
||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
) : (
|
||
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
|
||
<SideScorecardGrid
|
||
sides={round.sides}
|
||
players={players}
|
||
holesBySide={holesBySide}
|
||
holeOrder={holeOrder}
|
||
holesPlanned={round.holes_planned}
|
||
currentHole={activeHole}
|
||
readOnly={readOnly}
|
||
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
|
||
formatResult={formatResult}
|
||
onSelectHole={(n) => {
|
||
setHoleAndUrl(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
onOpenEntry={(sideId, holeNumber) => {
|
||
setHoleAndUrl(holeNumber)
|
||
setWizardSideId(sideId)
|
||
}}
|
||
/>
|
||
{!readOnly && round.sides.length === 2 && (
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
{round.sides.map((s, i) => (
|
||
<ShotMeasurementEntry
|
||
key={s.id}
|
||
roundId={roundId}
|
||
holeNumber={activeHole}
|
||
owner={{ kind: "side", id: s.id }}
|
||
ownBagClubs={ownBagClubs}
|
||
readOnly={readOnly}
|
||
variant="badge"
|
||
label={s.label?.trim() || (i === 0 ? "Side A" : "Side B")}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
|
||
<ChevronLeft aria-hidden="true" className="size-5" />
|
||
Forrige hull
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
|
||
Neste hull
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)
|
||
) : (
|
||
<>
|
||
{/* Hull-fokusert kortvisning (2026-08-03, erstatter det brede
|
||
gridet som STANDARDVISNING -- brukeren rapporterte at gridet
|
||
(§1 i teecup-scorekort-og-entry-spec.md, bygget 2026-07-27)
|
||
tok for stor plass i bredden på mobil. Se PlayerHoleCards for
|
||
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
|
||
"Vis hele scorekortet"-bryter under -- ingen informasjon er
|
||
fjernet. */}
|
||
{hole === null ? (
|
||
<div className="mt-5 flex justify-center py-8">
|
||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
) : (
|
||
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
|
||
<PlayerHoleCards
|
||
roundId={roundId}
|
||
players={players}
|
||
holesByParticipant={holesByParticipant}
|
||
holeOrder={holeOrder}
|
||
holesPlanned={round.holes_planned}
|
||
currentHole={activeHole}
|
||
readOnly={readOnly}
|
||
sides={round.sides}
|
||
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
|
||
formatResult={formatResult}
|
||
ownBagClubs={ownBagClubs}
|
||
onSelectHole={(n) => {
|
||
setHoleAndUrl(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
onPrevHole={goPrev}
|
||
onNextHole={goNext}
|
||
onOpenEntry={openPlayerOrTeamSideEntry}
|
||
onPatchParticipant={patchParticipant}
|
||
onSendScorecard={sendScorecard}
|
||
playFormat={round.play_format}
|
||
flagPlantByParticipant={flagPlantByParticipant}
|
||
flagOverflowByParticipant={flagOverflowByParticipant}
|
||
onPlantFlag={plantFlag}
|
||
onUnplantFlag={unplantFlag}
|
||
onSubmitFlagOverflowHole={updateFlagOverflowHole}
|
||
/>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowFullScorecard((v) => !v)}
|
||
aria-expanded={showFullScorecard}
|
||
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
Vis hele scorekortet
|
||
<ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||
</button>
|
||
{showFullScorecard && (
|
||
<div className="border-t border-border p-3">
|
||
<ScorecardGrid
|
||
players={players}
|
||
holesByParticipant={holesByParticipant}
|
||
holeOrder={holeOrder}
|
||
holesPlanned={round.holes_planned}
|
||
currentHole={activeHole}
|
||
readOnly={readOnly}
|
||
sides={round.sides}
|
||
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
|
||
formatResult={formatResult}
|
||
onSelectHole={(n) => {
|
||
setHoleAndUrl(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
onOpenEntry={openPlayerOrTeamSideEntry}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{round && viewerId && (
|
||
// Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
|
||
// rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat.
|
||
<div id="kommentarer" className="mt-6 scroll-mt-20">
|
||
<RoundMessages
|
||
roundId={roundId}
|
||
currentUserId={viewerId}
|
||
roundOwnerUserId={round.participants.find((p) => p.is_owner)?.user_id ?? ""}
|
||
refreshKey={messagesRefreshTick}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
|
||
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
|
||
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
|
||
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
|
||
{wizardSide && hole && (
|
||
<SideScoreWizard
|
||
sides={round.sides}
|
||
side={wizardSide}
|
||
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
||
hole={hole}
|
||
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
||
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
||
onSave={updateWizardSideScore}
|
||
onSelectParticipant={updateWizardSideSelection}
|
||
onClose={() => setWizardSideId(null)}
|
||
onAdvance={advanceWizardSide}
|
||
readOnly={readOnly}
|
||
isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)}
|
||
/>
|
||
)}
|
||
{wizardPlayer && hole && (
|
||
<ScoringWizard
|
||
roundId={roundId}
|
||
players={players}
|
||
player={wizardPlayer}
|
||
hole={hole}
|
||
stat={wizardStat}
|
||
onUpdate={updateWizardStat}
|
||
onClose={() => setWizardPlayerId(null)}
|
||
onAdvance={advanceWizardPlayer}
|
||
onSwitchPlayer={switchWizardPlayer}
|
||
readOnly={readOnly}
|
||
ownBagClubs={ownBagClubs}
|
||
isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
|
||
playFormat={round.play_format}
|
||
strokesReceived={wizardApiHole?.strokes_received ?? null}
|
||
/>
|
||
)}
|
||
</RoundPageShell>
|
||
)
|
||
}
|
||
|
||
// --- Scoringsveiviser (2026-07-26, auto-hopp lagt til 2026-07-28) ----------
|
||
// Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av
|
||
// en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men
|
||
// BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet
|
||
// for å kopiere konkurrentens nøyaktige skjermrekkefølge/design:
|
||
// - "strokes_only": ett steg (Slag)
|
||
// - "strokes_and_putts": to steg (Slag, Putter, Avstand første putt)
|
||
// - "full": fire steg (Slag, Putter, Avstand første putt, resten samlet)
|
||
// Slag/Putter hopper AUTOMATISK videre i det øyeblikket en verdi velges --
|
||
// ingen "Neste"-trykk nødvendig (etterspurt av bruker 2026-07-28: "Avstand
|
||
// første putt" lå tidligere PÅ SAMME steg som selve putt-tallet, som ville
|
||
// gjort feltet uoppnåelig hvis auto-hopp fyrte idet putt-tallet ble valgt --
|
||
// derfor et eget, eget steg for avstanden, som ELLERS ville vært umulig å
|
||
// nå). "Avstand"/"detaljer" er valgfrie/flerfelts og auto-hopper derfor
|
||
// ikke -- "Neste"/"Forrige"-knappene i bunnen fungerer fortsatt som
|
||
// manuell reserve for disse (og for å rette et allerede registrert steg).
|
||
// På siste steg blir knappen "Neste: {navn}" (bytter til neste spiller,
|
||
// nullstiller til steg 1) eller "Ferdig" (er spilleren den siste, lukker
|
||
// veiviseren og varsler forelderen om å gå videre til neste hull).
|
||
|
||
type WizardStep = "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"
|
||
|
||
function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
|
||
if (statLevel === "strokes_only") return ["strokes"]
|
||
if (statLevel === "strokes_and_putts") return ["strokes", "putts", "puttDistance"]
|
||
// ADR-078 (2026-08-16): "details" splittet i to egne skjermer -- retning
|
||
// (utslag/innspill, + kølle/slagmåling som hører til utslaget) og
|
||
// Chip/Bunker/Straffeslag/Anywayslag fikk hver sin egen skjerm i stedet
|
||
// for å dele én, etter tilbakemelding om at detaljene druknet under
|
||
// retningsvalget.
|
||
return ["strokes", "putts", "puttDistance", "direction", "holeDetails"]
|
||
}
|
||
|
||
// Hvilket HoleStat-felt (om noen) som "eier" auto-hopp for et gitt steg --
|
||
// null for steg uten noe entydig enkeltfelt å trigge på (flerfelts/valgfritt).
|
||
function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPuttBucket" | null {
|
||
if (step === "strokes") return "strokes"
|
||
if (step === "putts") return "putts"
|
||
if (step === "puttDistance") return "firstPuttBucket"
|
||
return null
|
||
}
|
||
|
||
function ScoringWizard({
|
||
roundId,
|
||
players,
|
||
player,
|
||
hole,
|
||
stat,
|
||
onUpdate,
|
||
onClose,
|
||
onAdvance,
|
||
onSwitchPlayer,
|
||
readOnly,
|
||
ownBagClubs,
|
||
isPending,
|
||
playFormat,
|
||
strokesReceived,
|
||
}: {
|
||
roundId: string
|
||
players: Player[]
|
||
player: Player
|
||
hole: Hole
|
||
stat: HoleStat
|
||
onUpdate: (patch: Partial<HoleStat>) => void
|
||
onClose: () => void
|
||
onAdvance: () => void
|
||
// Tilbake til en TIDLIGERE (eller "Ferdig"-markert) spiller midt i
|
||
// samlebånd-flyten (ADR-077) -- se kontekst-raden under. Trygt fordi
|
||
// hvert felt allerede lagres for seg med en gang (updateWizardStat),
|
||
// ingen fare for å miste uferdige felt ved bytte.
|
||
onSwitchPlayer: (playerId: string) => void
|
||
readOnly: boolean
|
||
ownBagClubs: string[]
|
||
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- sann når
|
||
// gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen,
|
||
// ikke bekreftet av serveren ennå.
|
||
isPending: boolean
|
||
// "Plukket opp" (migrasjon 038, Stableford) -- kun meningsfullt for
|
||
// slagspill/Stableford (match/skins/par-lag har egne, ubyggede
|
||
// hull-avgjørelsesmekanismer -- konsedering, ikke dette). Krever en
|
||
// kjent strokes_received for AKTUELT hull for at serveren skal kunne
|
||
// regne ut Net Double Bogey-scoren.
|
||
playFormat: string
|
||
strokesReceived: number | null
|
||
}) {
|
||
const playerIndex = players.findIndex((p) => p.id === player.id)
|
||
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
||
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
|
||
|
||
const steps = wizardStepsFor(player.statLevel)
|
||
const [stepIndex, setStepIndex] = useState(0)
|
||
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
|
||
// uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller
|
||
// slapp, i stedet for å starte på Slag igjen for den nye spilleren.
|
||
useEffect(() => {
|
||
setStepIndex(0)
|
||
}, [player.id])
|
||
|
||
const step = steps[Math.min(stepIndex, steps.length - 1)]
|
||
const isFirstStep = stepIndex === 0
|
||
const isLastStep = stepIndex === steps.length - 1
|
||
|
||
const canProceed =
|
||
step === "strokes" ? stat.strokes !== null || stat.pickedUp : step === "putts" ? stat.putts !== null : true
|
||
|
||
// Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi,
|
||
// hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith
|
||
// ValueRef` fanger om feltet ALLEREDE hadde en verdi idet vi ankom steget
|
||
// (et allerede utfylt hull skal ikke hoppe videre bare fordi veiviseren
|
||
// åpnes) -- kun en FERSK endring gjort AV BRUKEREN akkurat nå skal utløse
|
||
// hoppet. `firedRef` hindrer at samme hopp trigges to ganger.
|
||
const autoField = autoAdvanceFieldFor(step)
|
||
const enteredWithValueRef = useRef(false)
|
||
const firedRef = useRef(false)
|
||
useEffect(() => {
|
||
firedRef.current = false
|
||
enteredWithValueRef.current = autoField !== null && stat[autoField] !== null
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [step, player.id, hole.holeNumber])
|
||
useEffect(() => {
|
||
if (!autoField || firedRef.current || enteredWithValueRef.current) return
|
||
if (stat[autoField] !== null) {
|
||
firedRef.current = true
|
||
forward()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [stat.strokes, stat.putts, stat.firstPuttBucket])
|
||
|
||
// GIR-auto-inferens (2026-07-30): score - putts <= par - 2 GARANTERER
|
||
// greentreff i regulering (samme formel som round-stats.tsx sin isGir/
|
||
// round-detail.tsx sin showGir, som allerede styrer GIR-STATISTIKKEN) --
|
||
// da er det unødvendig å i tillegg kreve et manuelt "Innspill: Traff"-
|
||
// valg i DirectionCross-feltet under. Kun en FORHÅNDSUTFYLLING: rører
|
||
// ALDRI et allerede satt (manuelt ELLER tidligere auto-satt) approach-
|
||
// valg, og gjelder aldri motsatt vei (GIR usann sier ingenting om
|
||
// retning -- en scrambling-birdie gir identisk slag/putt-mønster som en
|
||
// ekte GIR). Trigges når "direction"-steget nås (ADR-078: flyttet fra det
|
||
// tidligere "details"-steget, som ble splittet i to), siden slag+putt
|
||
// begge må være kjent (kommer tidligere i wizard-rekkefølgen).
|
||
useEffect(() => {
|
||
if (step !== "direction") return
|
||
if (stat.approach !== null) return
|
||
if (stat.strokes === null || stat.putts === null) return
|
||
if (stat.strokes - stat.putts <= hole.par - 2) {
|
||
onUpdate({ approach: "hit" })
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [step, stat.strokes, stat.putts, stat.approach, hole.par])
|
||
|
||
function back() {
|
||
if (isFirstStep) {
|
||
onClose()
|
||
return
|
||
}
|
||
setStepIndex((i) => i - 1)
|
||
}
|
||
function forward() {
|
||
if (!isLastStep) {
|
||
setStepIndex((i) => i + 1)
|
||
return
|
||
}
|
||
onAdvance()
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Lukk veiviseren"
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
<X aria-hidden="true" className="size-5" />
|
||
</button>
|
||
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||
<span className="truncate text-base font-extrabold text-foreground">
|
||
Hull {hole.holeNumber} · Par {hole.par}
|
||
</span>
|
||
<span className="truncate text-sm font-semibold text-muted-foreground">{player.name}</span>
|
||
</div>
|
||
<div className="size-11 shrink-0" aria-hidden="true" />
|
||
</header>
|
||
|
||
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
|
||
konkurrentappen: man mister aldri oversikten over hvem som allerede
|
||
er ferdig på hullet. Interaktiv (ADR-077, 2026-08-15) -- trykk på
|
||
en ANNEN spiller (inkl. "Ferdig"-markerte) bytter veiviseren dit,
|
||
for å rette opp allerede registrerte data uten å måtte fullføre
|
||
hele samlebåndet først og lete opp kortet på nytt etterpå. Trygt
|
||
siden hvert felt allerede lagres for seg med en gang (se
|
||
onSwitchPlayer). */}
|
||
<div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||
{players.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
onClick={() => onSwitchPlayer(p.id)}
|
||
aria-current={p.id === player.id ? "true" : undefined}
|
||
className={cn(
|
||
"flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-bold transition-colors",
|
||
p.id === player.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||
)}
|
||
>
|
||
{p.name}
|
||
{p.completed && p.id !== player.id && (
|
||
<span className="text-xs font-semibold">· Ferdig</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{isPending && (
|
||
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||
Lagret lokalt · venter på synk
|
||
</p>
|
||
)}
|
||
|
||
<main className="flex min-h-0 flex-1 flex-col">
|
||
<ScrollFade className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||
{step === "strokes" && (
|
||
<>
|
||
<NumberPicker
|
||
label="Slag"
|
||
value={stat.strokes}
|
||
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
|
||
expandLabel="10+"
|
||
parValue={hole.par}
|
||
showGolfTerms
|
||
strokesReceived={strokesReceived}
|
||
onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
{/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) --
|
||
kun for slagspill/Stableford, og kun når serveren faktisk
|
||
kan regne ut Net Double Bogey (krever kjent strokes_received). */}
|
||
{(playFormat === "stroke" || playFormat === "stableford") && strokesReceived !== null && (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
aria-pressed={stat.pickedUp}
|
||
onClick={() => {
|
||
if (stat.pickedUp) {
|
||
onUpdate({ pickedUp: false, strokes: null, played: false })
|
||
return
|
||
}
|
||
onUpdate({ pickedUp: true, strokes: null, played: true })
|
||
forward()
|
||
}}
|
||
className={cn(
|
||
"flex min-h-11 items-center justify-center gap-2 rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-60",
|
||
stat.pickedUp
|
||
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
|
||
: "border-dashed border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{stat.pickedUp ? "Plukket opp — trykk for å angre" : "Plukket opp (0 poeng)"}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{step === "putts" && (
|
||
<NumberPicker
|
||
label="Putter"
|
||
value={stat.putts}
|
||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||
expandValues={[7, 8, 9, 10]}
|
||
expandLabel="7+"
|
||
maxValue={stat.strokes ?? undefined}
|
||
accent="orange"
|
||
onChange={(v) => onUpdate({ putts: v })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
{step === "puttDistance" && (
|
||
<ChoiceRow
|
||
label="Avstand første putt"
|
||
// Konsekvent "X-Ym"-mønster på ALLE seks, kun den siste
|
||
// (uunngåelig ubegrenset) bruker "+" -- 2026-08-11, reelt
|
||
// UX-funn: den forrige "<Xm ... 8m+"-blandingen brøt
|
||
// mønsteret på akkurat siste knapp, som da ble lett å
|
||
// overse. `value` uendret (PuttBucket/backend-kontrakt),
|
||
// kun visningsteksten er ny.
|
||
options={[
|
||
{ value: "<1m", label: "0-1m" },
|
||
{ value: "<2m", label: "1-2m" },
|
||
{ value: "<3m", label: "2-3m" },
|
||
{ value: "<5m", label: "3-5m" },
|
||
{ value: "<8m", label: "5-8m" },
|
||
{ value: "8m+", label: "8m+" },
|
||
]}
|
||
value={stat.firstPuttBucket}
|
||
onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
{/* ADR-078 (2026-08-16): "Retning" er nå EGEN skjerm -- inkl.
|
||
kølle/slagmåling, som begge hører til utslaget. Splittet fra
|
||
det tidligere "details"-steget etter tilbakemelding om at
|
||
Chip/Bunker/Straffeslag/Anywayslag druknet under retnings-
|
||
valget. */}
|
||
{step === "direction" && (
|
||
<>
|
||
<ClubPicker
|
||
label="Kølle brukt ved utslaget"
|
||
value={stat.club}
|
||
onChange={(club) => onUpdate({ club })}
|
||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
<ShotMeasurementEntry
|
||
roundId={roundId}
|
||
holeNumber={hole.holeNumber}
|
||
owner={{ kind: "participant", id: player.id }}
|
||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||
readOnly={readOnly}
|
||
variant="inline"
|
||
/>
|
||
|
||
<WizardSection title="Retning">
|
||
{hole.par !== 3 && (
|
||
<DirectionCross
|
||
label="Utslag"
|
||
variant="horizontal"
|
||
centerLabel="Fairway"
|
||
value={stat.teeShot}
|
||
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
<DirectionCross
|
||
label="Innspill"
|
||
variant="full"
|
||
centerLabel="Traff"
|
||
value={stat.approach}
|
||
onChange={(v) => onUpdate({ approach: v as Approach })}
|
||
readOnly={readOnly}
|
||
/>
|
||
</WizardSection>
|
||
</>
|
||
)}
|
||
|
||
{/* ADR-078 (2026-08-16): Chip/Bunker/Straffeslag/Anywayslag har nå
|
||
EGEN skjerm, splittet fra "Retning" (se over). Alle FIRE
|
||
tellerne er konseptuelt samme type data (antall småhendelser
|
||
per hull, typisk 0-3) og bruker ETT konsistent
|
||
+/--teller-flis-mønster (Stepper, hole-stat-inputs.tsx).
|
||
|
||
Layout (V0-designet, 2026-08-16): et FAST 2x2-rutenett av
|
||
teller-fliser i stedet for en høy stabel som etterlot mye
|
||
tomrom under nå som feltene har fått en egen skjerm. Fire
|
||
likeverdige felt leses da som ett samlet sett og fyller
|
||
kolonnen bevisst. grid-cols-2 er trygt -- og ALDRI
|
||
grid-cols-3 -- fordi seksjonen er fast max-w-sm (384px, se
|
||
containeren over): to fliser gir ~184px hver, rikelig til
|
||
flisens to 44px-knapper side om side, mens tre ville presset
|
||
hver flis under ~120px og fått knappene til å overlappe.
|
||
Viewport-uavhengig, så det holder på alle skjermbredder. */}
|
||
{step === "holeDetails" && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
<Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
<Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
<Stepper
|
||
label="Anywayslag"
|
||
value={stat.anywayStrokes ?? 0}
|
||
onChange={(v) => onUpdate({ anywayStrokes: v })}
|
||
readOnly={readOnly}
|
||
max={stat.strokes ?? undefined}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollFade>
|
||
</main>
|
||
|
||
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
|
||
<Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
|
||
{isFirstStep ? "Avbryt" : "Forrige"}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={!canProceed}
|
||
onClick={forward}
|
||
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
|
||
>
|
||
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||
</Button>
|
||
</footer>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- delt mellom
|
||
// tre bruksteder: ScoringWizard sitt detalj-steg (inline-variant,
|
||
// deltaker-eid hull), PlayerHoleCards sin alltid-synlige merkelapp
|
||
// (badge-variant, deltaker-eid, retroaktiv måling), og SideScorecardGrid
|
||
// sin tilsvarende merkelapp per side (badge-variant, side-eid hull --
|
||
// dekker delt-ball-formatene som ScoringWizard aldri når siden de bruker
|
||
// en egen, enklere side-veiviser uten dette steget). Samme
|
||
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
|
||
// 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
|
||
start_lat: number
|
||
start_lng: number
|
||
end_lat: number
|
||
end_lng: number
|
||
}
|
||
|
||
function ShotMeasurementEntry({
|
||
roundId,
|
||
holeNumber,
|
||
owner,
|
||
ownBagClubs,
|
||
readOnly,
|
||
variant,
|
||
label,
|
||
}: {
|
||
roundId: string
|
||
holeNumber: number
|
||
owner: { kind: "participant" | "side"; id: string }
|
||
ownBagClubs: string[]
|
||
readOnly: boolean
|
||
variant: "inline" | "badge"
|
||
label?: string
|
||
}) {
|
||
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
|
||
|
||
const [open, setOpen] = useState(false)
|
||
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
|
||
// 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 [submitError, setSubmitError] = useState<string | null>(null)
|
||
const [shareError, setShareError] = useState<string | null>(null)
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(urlBase, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: ShotRecord[]) => {
|
||
if (!cancelled) setShots(data)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [urlBase])
|
||
const count = shots.length
|
||
|
||
// Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen"
|
||
// følger spillerens EGEN gangretning på hullet, uten lagrede green-
|
||
// koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette
|
||
// hullet (start→slutt-retningen) -- `shots` er allerede sortert
|
||
// stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i
|
||
// rounds.py), så siste element ER forrige slag. `undefined` (ingen
|
||
// rotasjon regnet ut ennå) for det aller første slaget på hullet --
|
||
// MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord,
|
||
// se den komponentens dokumentasjon. Bevisst en STATISK retning, satt
|
||
// idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
|
||
// gangfart), se map-point-picker.tsx.
|
||
const initialBearing = useMemo(() => {
|
||
const previous = shots[shots.length - 1]
|
||
if (!previous) return undefined
|
||
return bearingDegrees(
|
||
{ lat: previous.start_lat, lng: previous.start_lng },
|
||
{ lat: previous.end_lat, lng: previous.end_lng },
|
||
)
|
||
}, [shots])
|
||
|
||
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
|
||
// 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
|
||
startMethod: "gps" | "map_tap"
|
||
startLat: number
|
||
startLng: number
|
||
endMethod: "gps" | "map_tap"
|
||
endLat: number
|
||
endLng: number
|
||
share: boolean
|
||
shareText: string
|
||
}) {
|
||
setSubmitError(null)
|
||
setSubmitting(true)
|
||
try {
|
||
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_method: result.endMethod,
|
||
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
|
||
}
|
||
let shot = await res.json()
|
||
setShareError(null)
|
||
if (result.share) {
|
||
const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
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])
|
||
setOpen(false)
|
||
} catch {
|
||
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function shareExisting(shot: ShotRecord) {
|
||
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) {
|
||
// 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 (
|
||
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
|
||
<div className="flex items-center gap-1.5">
|
||
{count > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setListOpen((o) => !o)
|
||
}}
|
||
aria-expanded={listOpen}
|
||
className={cn(
|
||
"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",
|
||
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",
|
||
)}
|
||
>
|
||
<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"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
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 && (
|
||
<ShotMeasurementSheet
|
||
holeNumber={holeNumber}
|
||
existingShotCount={count}
|
||
ownBagClubs={ownBagClubs}
|
||
initialBearing={initialBearing}
|
||
submitError={submitError}
|
||
submitting={submitting}
|
||
onSubmit={submitShot}
|
||
onCancel={() => setOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
|
||
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
|
||
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
|
||
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
|
||
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
|
||
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
|
||
function shotThumbnailUrl(shot: ShotRecord): string | null {
|
||
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
|
||
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
|
||
}
|
||
|
||
function ShotList({
|
||
shots,
|
||
onDelete,
|
||
onShare,
|
||
}: {
|
||
shots: ShotRecord[]
|
||
onDelete?: (shotId: string) => void
|
||
onShare?: (shot: ShotRecord) => void
|
||
}) {
|
||
return (
|
||
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
|
||
{shots.map((shot) => {
|
||
const thumbUrl = shotThumbnailUrl(shot)
|
||
return (
|
||
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
|
||
{thumbUrl ? (
|
||
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
|
||
<img
|
||
src={thumbUrl || "/placeholder.svg"}
|
||
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
|
||
className="size-14 shrink-0 rounded-lg border border-border object-cover"
|
||
/>
|
||
) : null}
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// --- Completed banner ------------------------------------------------------
|
||
|
||
function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
|
||
return (
|
||
<div className="mb-6 flex flex-col gap-3 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:flex-row sm:items-center sm:justify-between sm:p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||
<Check aria-hidden="true" className="size-6" />
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Alle hull er ført</span>
|
||
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Klar til å fullføre runden?</h2>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={completing}
|
||
onClick={onComplete}
|
||
className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:shrink-0"
|
||
>
|
||
{completing ? "Fullfører…" : "Fullfør runden"}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CompletedBanner({
|
||
players,
|
||
duration,
|
||
roundId,
|
||
}: {
|
||
players: Player[]
|
||
duration: string | null
|
||
roundId: string
|
||
}) {
|
||
return (
|
||
<div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||
<Trophy aria-hidden="true" className="size-6" />
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Ferdigspilt</span>
|
||
<h2 className="text-xl font-extrabold tracking-tight text-foreground">Runde fullført</h2>
|
||
</div>
|
||
{duration && (
|
||
<div className="ml-auto flex flex-col items-end">
|
||
<span className="text-xs font-medium text-muted-foreground">Tid brukt</span>
|
||
<span className="flex items-center gap-1.5 text-lg font-extrabold tabular-nums text-foreground">
|
||
<Clock aria-hidden="true" className="size-4 text-primary" />
|
||
{duration}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ul className="flex flex-col divide-y divide-primary/20 overflow-hidden rounded-2xl border border-primary/20 bg-card">
|
||
{players.map((player) => {
|
||
const counts = player.countsForHandicap && player.scoreDifferential !== null
|
||
return (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||
<span className="text-base font-bold text-foreground">{player.name}</span>
|
||
{counts ? (
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="text-xs font-medium text-muted-foreground">Hcp spilt til</span>
|
||
<span className="text-lg font-extrabold tabular-nums text-foreground">
|
||
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-sm font-semibold text-muted-foreground">Telte ikke mot HCP</span>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
{/* Scorekort og statistikk slått sammen til ÉN side (ADR-040
|
||
Beslutning D) -- ett felles knapp i stedet for to separate. */}
|
||
<Link
|
||
href={`/my-rounds/${roundId}/scorecard`}
|
||
className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||
>
|
||
Se scorekort og statistikk
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Score så langt (kompakt linje + utvidbar full oversikt) --------------
|
||
// Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app --
|
||
// samme "Vis full oversikt"-mønster som allerede etablert i
|
||
// session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt
|
||
// spillers frittstående runde (ingen motstander-side å vise her).
|
||
//
|
||
// Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/
|
||
// kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling +
|
||
// gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen
|
||
// egen "spilleform"-innstilling for frittstående runder -- stableford
|
||
// beregnes derfor alltid ut fra netto score der det er mulig (krever
|
||
// registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill
|
||
// eller stableford -- rent informativt, ikke en offisiell poengsum.
|
||
|
||
// Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0,
|
||
// netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre.
|
||
function stablefordPoints(netScore: number, par: number): number {
|
||
return Math.max(0, 2 - (netScore - par))
|
||
}
|
||
|
||
function avgScoreToPar(holes: ApiHole[]): number | null {
|
||
const played = holes.filter((h) => h.played && h.score !== null)
|
||
if (played.length === 0) return null
|
||
const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0)
|
||
return diffSum / played.length
|
||
}
|
||
|
||
function formatSignedAvg(n: number): string {
|
||
const fixed = Math.abs(n).toFixed(2)
|
||
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
|
||
}
|
||
|
||
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
if (!holes || holes.length === 0) return null
|
||
|
||
const orderedHoles = order
|
||
.map((n) => holes.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => h !== undefined)
|
||
const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null)
|
||
if (playedSoFar.length === 0) return null
|
||
|
||
const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0)
|
||
const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0)
|
||
const toPar = totalStrokes - totalPar
|
||
const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}`
|
||
|
||
// Netto/stableford krever at deltakeren har en beregnet course handicap
|
||
// (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten
|
||
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
|
||
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
||
const hasNetto = withNetto.length > 0
|
||
const nettoTotal = hasNetto
|
||
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
||
: null
|
||
const stablefordTotal = hasNetto
|
||
? withNetto.reduce(
|
||
(sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par),
|
||
0,
|
||
)
|
||
: null
|
||
|
||
const puttsPlayed = orderedHoles.filter((h) => h.putts !== null)
|
||
const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null
|
||
const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null)
|
||
const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null
|
||
const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null)
|
||
const bunkerTotal =
|
||
bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null
|
||
const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null)
|
||
const penaltyTotal =
|
||
penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null
|
||
const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null)
|
||
const anywayTotal =
|
||
anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null
|
||
|
||
// Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som
|
||
// registrerings-skjemaet -- DirectionCross for Utslag vises ikke der).
|
||
const fairwayTracked = orderedHoles.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
|
||
const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway")
|
||
const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left")
|
||
const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right")
|
||
const fairwayMiss = [...fairwayLeft, ...fairwayRight]
|
||
const avgToParFairwayHit = avgScoreToPar(fairwayHit)
|
||
const avgToParFairwayMiss = avgScoreToPar(fairwayMiss)
|
||
|
||
const approachTracked = orderedHoles.filter((h) => h.approach_result !== null)
|
||
const approachHit = approachTracked.filter((h) => h.approach_result === "hit")
|
||
const approachShort = approachTracked.filter((h) => h.approach_result === "short")
|
||
const approachLong = approachTracked.filter((h) => h.approach_result === "long")
|
||
const approachLeft = approachTracked.filter((h) => h.approach_result === "left")
|
||
const approachRight = approachTracked.filter((h) => h.approach_result === "right")
|
||
const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight]
|
||
const avgToParApproachHit = avgScoreToPar(approachHit)
|
||
const avgToParApproachMiss = avgScoreToPar(approachMiss)
|
||
|
||
let running = 0
|
||
|
||
return (
|
||
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
aria-expanded={expanded}
|
||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||
>
|
||
<span className="text-sm font-semibold text-foreground text-pretty">
|
||
Så langt: {playedSoFar.length} hull · {totalStrokes} slag · {toParLabel} til par
|
||
{hasNetto && ` · Netto ${nettoTotal! > 0 ? "+" : ""}${nettoTotal}`}
|
||
</span>
|
||
<span className="shrink-0 text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis full oversikt"}</span>
|
||
</button>
|
||
{expanded && (
|
||
<div className="border-t border-border">
|
||
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
|
||
<StatPill label="Slag" value={String(totalStrokes)} />
|
||
<StatPill label="Til par" value={toParLabel} />
|
||
{hasNetto && <StatPill label="Netto" value={nettoTotal! > 0 ? `+${nettoTotal}` : String(nettoTotal)} />}
|
||
{hasNetto && <StatPill label="Stableford" value={`${stablefordTotal} poeng`} />}
|
||
{puttsTotal !== null && <StatPill label="Putt" value={String(puttsTotal)} />}
|
||
{chipTotal !== null && <StatPill label="Chip" value={String(chipTotal)} />}
|
||
{bunkerTotal !== null && <StatPill label="Bunker" value={String(bunkerTotal)} />}
|
||
{penaltyTotal !== null && <StatPill label="Straffeslag" value={String(penaltyTotal)} />}
|
||
{anywayTotal !== null && <StatPill label="Anywayslag" value={String(anywayTotal)} />}
|
||
</div>
|
||
|
||
<div className="overflow-x-auto border-t border-border">
|
||
<table className="w-full min-w-[420px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-left text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||
<th className="px-3 py-2">Hull</th>
|
||
<th className="px-3 py-2">Par</th>
|
||
<th className="px-3 py-2">Score</th>
|
||
<th className="px-3 py-2">Netto</th>
|
||
{hasNetto && <th className="px-3 py-2">Stableford</th>}
|
||
<th className="px-3 py-2">Sum</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{orderedHoles.map((h) => {
|
||
const played = h.played && h.score !== null
|
||
if (played) running += h.score as number
|
||
const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null
|
||
const points = played && netto !== null ? stablefordPoints(netto, h.par) : null
|
||
return (
|
||
<tr key={h.hole_number} className="border-b border-border last:border-b-0">
|
||
<td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
|
||
<td className="px-3 py-2 tabular-nums font-semibold text-foreground">
|
||
{played ? (h.picked_up ? "PU" : h.score) : "–"}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? "–"}</td>
|
||
{hasNetto && (
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? "–"}</td>
|
||
)}
|
||
<td className="px-3 py-2 tabular-nums font-bold text-foreground">{played ? running : "–"}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{fairwayTracked.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||
<span className="text-sm font-bold text-foreground">Fairwaytreff</span>
|
||
<DistributionBar
|
||
segments={[
|
||
{ label: "Fairway", count: fairwayHit.length, colorClass: "bg-emerald-500" },
|
||
{ label: "Venstre", count: fairwayLeft.length, colorClass: "bg-amber-500" },
|
||
{ label: "Høyre", count: fairwayRight.length, colorClass: "bg-rose-500" },
|
||
]}
|
||
/>
|
||
{(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
|
||
{avgToParFairwayHit !== null && (
|
||
<span>
|
||
Til par ved fairwaytreff:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayHit)}</strong>
|
||
</span>
|
||
)}
|
||
{avgToParFairwayMiss !== null && (
|
||
<span>
|
||
Til par ved bom på fairway:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayMiss)}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{approachTracked.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||
<span className="text-sm font-bold text-foreground">Innspill</span>
|
||
<DistributionBar
|
||
segments={[
|
||
{ label: "Traff", count: approachHit.length, colorClass: "bg-emerald-500" },
|
||
{ label: "Kort", count: approachShort.length, colorClass: "bg-amber-500" },
|
||
{ label: "Langt", count: approachLong.length, colorClass: "bg-orange-500" },
|
||
{ label: "Venstre", count: approachLeft.length, colorClass: "bg-sky-500" },
|
||
{ label: "Høyre", count: approachRight.length, colorClass: "bg-rose-500" },
|
||
]}
|
||
/>
|
||
{(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
|
||
{avgToParApproachHit !== null && (
|
||
<span>
|
||
Til par ved treff på innspill:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
|
||
</span>
|
||
)}
|
||
{avgToParApproachMiss !== null && (
|
||
<span>
|
||
Til par ved bom på innspill:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParApproachMiss)}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatPill({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-0.5 rounded-xl border border-border bg-background px-2 py-2.5 text-center">
|
||
<span className="text-lg font-extrabold tabular-nums text-foreground">{value}</span>
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Ett rektangel delt i fargede segmenter proporsjonalt med antall --
|
||
// samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men
|
||
// generalisert til N kategorier (ikke bare to lag).
|
||
function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) {
|
||
const total = segments.reduce((sum, s) => sum + s.count, 0)
|
||
if (total === 0) return null
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex h-5 w-full overflow-hidden rounded-full border border-border">
|
||
{segments
|
||
.filter((s) => s.count > 0)
|
||
.map((s) => (
|
||
<div key={s.label} aria-hidden="true" style={{ width: `${(s.count / total) * 100}%` }} className={s.colorClass} />
|
||
))}
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||
{segments
|
||
.filter((s) => s.count > 0)
|
||
.map((s) => (
|
||
<span key={s.label} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||
<span aria-hidden="true" className={cn("inline-block size-2.5 rounded-full", s.colorClass)} />
|
||
{s.label} {Math.round((s.count / total) * 100)}% ({s.count})
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Sider (ADR-039 Beslutning A) -------------------------------------------
|
||
// Kun for match/fourball/foursome/greensome/scramble -- nøyaktig to sider,
|
||
// hver med et forventet spillerantall avhengig av format. Sidene opprettes
|
||
// tomme (POST .../sides), deltakere tildeles/flyttes deretter via PATCH
|
||
// .../participants/{id} med round_side_id.
|
||
|
||
function SidesPanel({
|
||
roundId,
|
||
round,
|
||
players,
|
||
canManage,
|
||
onSidesChanged,
|
||
onPatchParticipant,
|
||
}: {
|
||
roundId: string
|
||
round: ApiRound
|
||
players: Player[]
|
||
canManage: boolean
|
||
onSidesChanged: () => Promise<void>
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [labelDrafts, setLabelDrafts] = useState<Record<number, string>>({})
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const expected = SIDE_PLAYER_COUNT[round.play_format] ?? 1
|
||
const slots: (ApiSide | null)[] = [round.sides[0] ?? null, round.sides[1] ?? null]
|
||
const unassigned = players.filter((p) => p.roundSideId === null)
|
||
|
||
async function createSide(slotIndex: number) {
|
||
setBusy(true)
|
||
setError(null)
|
||
const res = await fetch(`/rounds/${roundId}/sides`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ label: labelDrafts[slotIndex]?.trim() || null }),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
setError(body?.detail?.message ?? "Klarte ikke å opprette siden.")
|
||
setBusy(false)
|
||
return
|
||
}
|
||
await onSidesChanged()
|
||
setBusy(false)
|
||
}
|
||
|
||
async function deleteSide(sideId: string) {
|
||
if (!confirm("Slette denne siden? Deltakerne blir liggende i runden, uten side-tilhørighet.")) return
|
||
setBusy(true)
|
||
setError(null)
|
||
const res = await fetch(`/rounds/${roundId}/sides/${sideId}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
setError(body?.detail?.message ?? "Klarte ikke å slette siden.")
|
||
}
|
||
await onSidesChanged()
|
||
setBusy(false)
|
||
}
|
||
|
||
async function assign(participantId: string, sideId: string | null) {
|
||
setBusy(true)
|
||
setError(null)
|
||
const result = await onPatchParticipant(participantId, { round_side_id: sideId })
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
} else {
|
||
// patchParticipant oppdaterer kun deltaker-listen lokalt -- setup_complete/
|
||
// setup_message/playing_handicap er beregnet SERVER-side og krever en
|
||
// full runde-refetch for å ikke bli stående utdatert (2026-07-28, funnet
|
||
// under browserverifisering: meldingen "ikke tildelt en side" ble
|
||
// stående etter en vellykket tildeling).
|
||
await onSidesChanged()
|
||
}
|
||
setBusy(false)
|
||
}
|
||
|
||
if (!canManage && round.sides.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">
|
||
Sider · {expected} spiller{expected !== 1 ? "e" : ""} per side
|
||
</span>
|
||
</div>
|
||
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{slots.map((side, slotIndex) => {
|
||
const assignedPlayers = side ? players.filter((p) => p.roundSideId === side.id) : []
|
||
return (
|
||
<div key={slotIndex} className="flex flex-col gap-2 rounded-xl border border-dashed border-border p-3">
|
||
{side ? (
|
||
<>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
|
||
</span>
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => deleteSide(side.id)}
|
||
aria-label="Slett siden"
|
||
className="flex min-h-8 min-w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{assignedPlayers.length}/{expected} spillere
|
||
{assignedPlayers.some((p) => p.playingHandicap !== null) && (
|
||
<>
|
||
{" · "}
|
||
Match-hcp{" "}
|
||
{assignedPlayers
|
||
.filter((p) => p.playingHandicap !== null)
|
||
.map((p) => p.playingHandicap)
|
||
.join("/")}
|
||
</>
|
||
)}
|
||
</span>
|
||
{assignedPlayers.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Ingen spillere ennå.</p>
|
||
) : (
|
||
<ul className="flex flex-wrap gap-1.5">
|
||
{assignedPlayers.map((p) => (
|
||
<li
|
||
key={p.id}
|
||
className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 text-sm font-semibold text-foreground"
|
||
>
|
||
{p.name}
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => assign(p.id, null)}
|
||
aria-label={`Fjern ${p.name} fra siden`}
|
||
className="flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-destructive"
|
||
>
|
||
<X aria-hidden="true" className="size-3" />
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</>
|
||
) : canManage ? (
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
value={labelDrafts[slotIndex] ?? ""}
|
||
onChange={(e) => setLabelDrafts((prev) => ({ ...prev, [slotIndex]: e.target.value }))}
|
||
placeholder={`Navn på side (valgfritt)`}
|
||
className="h-10 rounded-xl text-sm"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={() => createSide(slotIndex)}
|
||
className="h-10 rounded-xl text-sm font-bold"
|
||
>
|
||
Opprett side
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Ikke opprettet ennå.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{canManage && unassigned.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||
<span className="text-sm font-semibold text-foreground">Ikke tildelt en side</span>
|
||
<ul className="flex flex-col gap-2">
|
||
{unassigned.map((p) => (
|
||
<li key={p.id} className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-semibold text-foreground">{p.name}</span>
|
||
{slots.map((side, slotIndex) => {
|
||
if (!side) return null
|
||
const count = players.filter((x) => x.roundSideId === side.id).length
|
||
const full = count >= expected
|
||
return (
|
||
<button
|
||
key={slotIndex}
|
||
type="button"
|
||
disabled={busy || full}
|
||
onClick={() => assign(p.id, side.id)}
|
||
className="inline-flex min-h-8 items-center rounded-full border border-border bg-card px-3 text-xs font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||
>
|
||
→ {side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
|
||
</button>
|
||
)
|
||
})}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Løpende matchstatus / skins-tavle (ADR-039) ----------------------------
|
||
// Avledet ved LESING (aldri lagret) fra GET .../format-result -- samme
|
||
// filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger
|
||
// ingen egen visning her (eksisterende leaderboard dekker det).
|
||
|
||
// Per-hull-oppløsning (2026-07-29, samme kontrakt som round-scorecard.tsx sin
|
||
// ApiFormatHoleEntry/ApiFormatHoleOut) -- gjør det mulig å farge hver
|
||
// scorecelle etter hvem som VANT hullet, og å regne en løpende Stilling-rad,
|
||
// i stedet for kun et sluttresultat.
|
||
type ApiFormatHoleEntry = {
|
||
id: string
|
||
label: string
|
||
side: "a" | "b" | null
|
||
gross: number | null
|
||
net: number | null
|
||
strokes_received: number | null
|
||
counted: boolean | null
|
||
// Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE hullet.
|
||
points: number | null
|
||
}
|
||
|
||
type ApiFormatHoleOut = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
entries: ApiFormatHoleEntry[]
|
||
result: string | null
|
||
winner_ids: string[] | null
|
||
carried: boolean | null
|
||
}
|
||
|
||
type ApiFlagResult = {
|
||
holes_completed: number
|
||
ran_out: boolean
|
||
strokes_remaining: number
|
||
label: string
|
||
}
|
||
|
||
type ApiBBBHole = {
|
||
hole_number: number
|
||
bingo_participant_id: string | null
|
||
bango_participant_id: string | null
|
||
bongo_participant_id: string | null
|
||
}
|
||
|
||
// Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14).
|
||
type ApiFlagPlant = {
|
||
id: string
|
||
lap: number
|
||
hole_number: number
|
||
lat: number
|
||
lng: number
|
||
on_green: boolean
|
||
distance_to_pin_cm: number | null
|
||
planted_by_user_id: string
|
||
planted_at: string
|
||
}
|
||
|
||
type ApiFlagOverflowHole = {
|
||
lap: number
|
||
hole_number: number
|
||
score: number | null
|
||
played: boolean
|
||
}
|
||
|
||
// Speiler _flag_current_position (app/routers/rounds.py) presist -- brukt
|
||
// KUN til å vise riktig "hull N"/"lap L" i UI-et FØR innsending; serveren
|
||
// er alltid den autoritative kilden og validerer på nytt (se plantFlag).
|
||
function computeFlagCurrentPosition(
|
||
holeOrder: number[],
|
||
holes: ApiHole[] | undefined,
|
||
overflowHoles: ApiFlagOverflowHole[] | undefined,
|
||
): { lap: number; holeNumber: number } {
|
||
const lap1Played = new Set((holes ?? []).filter((h) => h.played).map((h) => h.hole_number))
|
||
for (const h of holeOrder) {
|
||
if (!lap1Played.has(h)) return { lap: 1, holeNumber: h }
|
||
}
|
||
let lap = 2
|
||
while (true) {
|
||
const lapPlayed = new Set(
|
||
(overflowHoles ?? []).filter((h) => h.lap === lap && h.played).map((h) => h.hole_number),
|
||
)
|
||
for (const h of holeOrder) {
|
||
if (!lapPlayed.has(h)) return { lap, holeNumber: h }
|
||
}
|
||
lap += 1
|
||
}
|
||
}
|
||
|
||
type ApiFormatResult = {
|
||
play_format: string
|
||
ready: boolean
|
||
match_lead: number | null
|
||
match_holes_played: number | null
|
||
match_holes_remaining: number | null
|
||
match_is_closed: boolean | null
|
||
match_is_dormie: boolean | null
|
||
match_status_text: string | null
|
||
hole_results: string[] | null
|
||
skins_won: Record<string, number> | null
|
||
// Åtte nye formater (2026-07-30) -- se app/routers/rounds.py sin
|
||
// RoundFormatResultOut for full kontrakt.
|
||
copenhagen_points: Record<string, number> | null
|
||
bbb_points: Record<string, number> | null
|
||
flag_results: Record<string, ApiFlagResult> | null
|
||
shamble_team_score: number | null
|
||
money_ball_team_score: number | null
|
||
hlh_points_a: number | null
|
||
hlh_points_b: number | null
|
||
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
|
||
// side "a" = laget, side "b" = individualspilleren. margin > 0 = laget leder.
|
||
scramble_solo_team_net: number | null
|
||
scramble_solo_individual_net: number | null
|
||
scramble_solo_margin: number | null
|
||
holes: ApiFormatHoleOut[] | null
|
||
}
|
||
|
||
// Kort statustekst UTEN backend sin bokstavelige "(A)"/"(B)"-suffiks --
|
||
// identitetsbanneret viser navnet separat (se IdentitySide), så suffikset
|
||
// ville vært en duplikat. Speiler handicap_engine.py sin
|
||
// MatchState.describe() presist.
|
||
function formatMatchStatus(lead: number, holesRemaining: number, isClosed: boolean, isDormie: boolean): string {
|
||
const margin = Math.abs(lead)
|
||
if (isClosed) return holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
|
||
if (lead === 0) return "AS"
|
||
return isDormie ? `Dormie ${margin}` : `${margin} UP`
|
||
}
|
||
|
||
// Speiler samme funksjon i handicap_engine.py/round-scorecard.tsx, men
|
||
// regnet ETT PREFIKS om gangen langs `holeOrder` (spillerekkefølgen, ikke
|
||
// nødvendigvis fysisk hullnummer) -- gir en løpende stilling PER HULL i
|
||
// stedet for kun sluttresultatet. Stopper (viser ingen verdi videre) ved
|
||
// første hull uten et registrert resultat, siden hull avgjøres sekvensielt.
|
||
function matchStillingByHole(
|
||
holeOrder: number[],
|
||
formatResult: ApiFormatResult | null,
|
||
totalHoles: number,
|
||
): Map<number, { label: string; tone: "a" | "b" | "neutral" }> {
|
||
const out = new Map<number, { label: string; tone: "a" | "b" | "neutral" }>()
|
||
if (!formatResult?.holes) return out
|
||
const byHole = new Map(formatResult.holes.map((h) => [h.hole_number, h]))
|
||
let lead = 0
|
||
let holesPlayed = 0
|
||
for (const n of holeOrder) {
|
||
const h = byHole.get(n)
|
||
if (!h || !h.result) break
|
||
holesPlayed += 1
|
||
lead += h.result === "a" ? 1 : h.result === "b" ? -1 : 0
|
||
const holesRemaining = totalHoles - holesPlayed
|
||
const isClosed = Math.abs(lead) > holesRemaining
|
||
const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
|
||
out.set(n, {
|
||
label: formatMatchStatus(lead, holesRemaining, isClosed, isDormie),
|
||
tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral",
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Identitet (navn+HCP) for en side i banneret/gridet -- viser SPILLERENS
|
||
// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome-
|
||
// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers
|
||
// sidens eget (evt. generiske) navn uten noen entydig HCP å vise.
|
||
// Samme adapter-logikk som round-leaderboard.tsx sin buildScrambleSoloResult
|
||
// (lokal kopi, prosjektets etablerte mønster) -- bygger ScrambleSoloResultView
|
||
// sin props-form fra det generiske ApiFormatResult-svaret.
|
||
function buildScrambleSoloResult(round: ApiRound, result: ApiFormatResult): ScrambleSoloResult {
|
||
const teamSide = round.sides.find((s) => s.side_role === "team")
|
||
const individualSide = round.sides.find((s) => s.side_role === "individual")
|
||
const firstHoleEntries = result.holes?.[0]?.entries ?? []
|
||
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
|
||
const individualLabel =
|
||
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
|
||
|
||
const holes: ScrambleSoloResult["holes"] = (result.holes ?? []).map((h) => {
|
||
const teamEntry = h.entries.find((e) => e.side === "a")
|
||
const individualEntry = h.entries.find((e) => e.side === "b")
|
||
const holeResult: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
|
||
return {
|
||
holeNumber: h.hole_number,
|
||
par: h.par,
|
||
strokeIndex: h.stroke_index,
|
||
result: holeResult,
|
||
team: {
|
||
gross: teamEntry?.gross ?? 0,
|
||
net: teamEntry?.net ?? 0,
|
||
strokesReceived: teamEntry?.strokes_received ?? 0,
|
||
},
|
||
individual: {
|
||
gross: individualEntry?.gross ?? 0,
|
||
net: individualEntry?.net ?? 0,
|
||
strokesReceived: individualEntry?.strokes_received ?? 0,
|
||
},
|
||
}
|
||
})
|
||
|
||
return {
|
||
ready: result.ready,
|
||
teamLabel,
|
||
individualLabel,
|
||
teamNet: result.scramble_solo_team_net,
|
||
individualNet: result.scramble_solo_individual_net,
|
||
margin: result.scramble_solo_margin,
|
||
holes,
|
||
}
|
||
}
|
||
|
||
function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } {
|
||
const idx = side === "a" ? 0 : 1
|
||
const sideId = round.sides[idx]?.id
|
||
const sidePlayers = players.filter((p) => p.roundSideId === sideId)
|
||
if (sidePlayers.length === 1) return { name: sidePlayers[0].name, hcp: sidePlayers[0].playingHandicap }
|
||
return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null }
|
||
}
|
||
|
||
function IdentitySide({ identity, side }: { identity: { name: string; hcp: number | null }; side: "a" | "b" }) {
|
||
return (
|
||
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", side === "a" ? "items-start" : "items-end")}>
|
||
<div className={cn("flex min-w-0 items-center gap-1.5", side === "b" && "flex-row-reverse")}>
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
/>
|
||
<span className="min-w-0 truncate text-sm font-extrabold text-foreground">{identity.name}</span>
|
||
</div>
|
||
{identity.hcp !== null && (
|
||
<span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {identity.hcp}</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Territorium-bar (2026-07-29, erstatter det tidligere flate, likt-delte
|
||
// banneret) -- den ledende sidens sone strekker seg PROPORSJONALT forbi
|
||
// midtlinjen inn på motstanderens halvdel (brukerens eget forslag: "hele
|
||
// den halve delen som tilhører den som er en opp er markert over på
|
||
// motstanderens halvdel"), i stedet for en statisk 50/50-boks med tekst i
|
||
// midten. Ved AS (eller ingen hull spilt ennå) er begge soner nøyaktig like
|
||
// store OG likt (nøytralt) farget -- ingen side skal se ut til å lede.
|
||
// Bruker samme proporsjonale to-fargede-bar-idé som SegmentedBar
|
||
// (tournament-leaderboard.tsx), tilpasset lead-i-hull i stedet for poeng.
|
||
function leadZoneFraction(lead: number, totalHoles: number): number {
|
||
if (totalHoles <= 0) return 0.5
|
||
const fraction = 0.5 + (lead / totalHoles) * 0.5
|
||
return Math.min(0.82, Math.max(0.18, fraction))
|
||
}
|
||
|
||
function LeadZone({
|
||
identity,
|
||
side,
|
||
dominant,
|
||
}: {
|
||
identity: { name: string; hcp: number | null }
|
||
side: "a" | "b"
|
||
dominant: boolean
|
||
}) {
|
||
const textTone = dominant ? (side === "a" ? "text-primary-foreground" : "text-brand-orange-foreground") : "text-foreground"
|
||
const subTone = dominant
|
||
? side === "a"
|
||
? "text-primary-foreground/75"
|
||
: "text-brand-orange-foreground/75"
|
||
: "text-muted-foreground"
|
||
// Merk: den ytre boksen STREKKER SEG (ingen items-start/items-end her --
|
||
// det ville sizet boksen etter innholdets egen bredde, ikke etter sonens
|
||
// faktiske tildelte bredde, og kunne visuelt lekke inn i midt-kolonnen på
|
||
// smale skjermer). Venstre/høyre-justering skjer i stedet med
|
||
// justify-start/justify-end og text-align INNI en boks som alltid har
|
||
// sonens fulle, korrekte bredde -- truncate virker da presist.
|
||
return (
|
||
<div className="flex h-full min-w-0 w-full flex-col justify-center gap-0.5 px-3">
|
||
<div
|
||
className={cn(
|
||
"flex min-w-0 items-center gap-1.5",
|
||
side === "a" ? "justify-start" : "justify-end",
|
||
side === "b" && "flex-row-reverse",
|
||
)}
|
||
>
|
||
{!dominant && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
/>
|
||
)}
|
||
<span className={cn("min-w-0 truncate text-sm font-extrabold", textTone)}>{identity.name}</span>
|
||
</div>
|
||
{identity.hcp !== null && (
|
||
<span
|
||
className={cn(
|
||
"truncate text-xs font-bold tabular-nums",
|
||
subTone,
|
||
side === "a" ? "text-left" : "text-right",
|
||
)}
|
||
>
|
||
HCP {identity.hcp}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type ApiFlightSummary = {
|
||
round_id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
played_at: string
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participant_count: number
|
||
is_anchor: boolean
|
||
}
|
||
|
||
// Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs
|
||
// gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg
|
||
// til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten
|
||
// (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne
|
||
// flightens dato/starthull/hullantall/spilleform via URL-parametre, men
|
||
// banen velges på nytt (samme lave friksjon som normal bane-søk ellers).
|
||
function FlightGroupPanel({
|
||
roundId,
|
||
round,
|
||
isOwnerViewer,
|
||
}: {
|
||
roundId: string
|
||
round: ApiRound
|
||
isOwnerViewer: boolean
|
||
}) {
|
||
const router = useRouter()
|
||
const [flights, setFlights] = useState<ApiFlightSummary[] | null>(null)
|
||
const [adding, setAdding] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => {
|
||
if (!cancelled) setFlights(data?.flights ?? null)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setFlights(null)
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round.flight_group_id])
|
||
|
||
async function addFlight() {
|
||
if (!isOwnerViewer) return
|
||
setAdding(true)
|
||
try {
|
||
let groupId = round.flight_group_id
|
||
if (!groupId) {
|
||
groupId = crypto.randomUUID()
|
||
await fetch(`/rounds/${roundId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ flight_group_id: groupId }),
|
||
})
|
||
}
|
||
const params = new URLSearchParams({
|
||
flightGroupId: groupId,
|
||
playedAt: round.played_at,
|
||
startHole: String(round.start_hole),
|
||
holes: String(round.holes_planned),
|
||
playFormat: round.play_format,
|
||
})
|
||
router.push(`/my-rounds/new?${params.toString()}`)
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
const others = (flights ?? []).filter((f) => f.round_id !== roundId)
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||
<Users aria-hidden="true" className="size-4 text-primary" />
|
||
Flighter
|
||
</span>
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
onClick={addFlight}
|
||
disabled={adding}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
|
||
>
|
||
<Plus aria-hidden="true" className="size-4" />
|
||
{adding ? "Setter opp…" : "Legg til en flight til"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{others.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag --
|
||
de deler da leaderboard med denne flighten.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{(flights ?? []).map((f) => (
|
||
<li key={f.round_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
{f.round_id === roundId ? (
|
||
<span className="text-sm font-semibold text-foreground">
|
||
{f.name || f.course_name_snapshot}{" "}
|
||
<span className="font-normal text-muted-foreground">(denne)</span>
|
||
</span>
|
||
) : (
|
||
<Link
|
||
href={`/my-rounds/${f.round_id}`}
|
||
className="text-sm font-semibold text-primary hover:underline"
|
||
>
|
||
{f.name || f.course_name_snapshot}
|
||
</Link>
|
||
)}
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
|
||
{f.completed_at ? " · Ferdig" : ""}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Link
|
||
href={`/my-rounds/${roundId}/flights`}
|
||
className="inline-flex min-h-11 items-center gap-1.5 self-start text-sm font-bold text-primary hover:underline"
|
||
>
|
||
Se samlet leaderboard for flightene
|
||
<ArrowRight aria-hidden="true" className="size-4" />
|
||
</Link>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FormatResultPanel({
|
||
round,
|
||
players,
|
||
result,
|
||
}: {
|
||
round: ApiRound
|
||
players: Player[]
|
||
result: ApiFormatResult | null
|
||
}) {
|
||
if (round.play_format === "stroke" || round.play_format === "stableford" || !result) return null
|
||
|
||
if (round.play_format === "skins") {
|
||
const won = result.skins_won ?? {}
|
||
const rows = players
|
||
.map((p) => ({ player: p, skins: won[p.id] ?? 0 }))
|
||
.sort((a, b) => b.skins - a.skins)
|
||
const totalAwarded = Object.values(won).reduce((sum, v) => sum + v, 0)
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">Skins-tavle</span>
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
|
||
</span>
|
||
</div>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map(({ player, skins }) => (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Københavner (2026-07-30) -- flatt felt, poengtabell (6 poeng deles
|
||
// per hull mellom nøyaktig 3 spillere -- flest poeng totalt vinner).
|
||
if (round.play_format === "copenhagen") {
|
||
const points = result.copenhagen_points ?? {}
|
||
const rows = players
|
||
.map((p) => ({ player: p, points: points[p.id] ?? 0 }))
|
||
.sort((a, b) => b.points - a.points)
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Københavner -- poengtavle</span>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map(({ player, points: pts }) => (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Bingo Bango Bongo (2026-07-30) -- flatt felt, poengtabell (registreres
|
||
// per hull via egen BBBHolePicker, se lenger ned i Score-fanen).
|
||
if (round.play_format === "bbb") {
|
||
const points = result.bbb_points ?? {}
|
||
const rows = players
|
||
.map((p) => ({ player: p, points: points[p.id] ?? 0 }))
|
||
.sort((a, b) => b.points - a.points)
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- poengtavle</span>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map(({ player, points: pts }) => (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Flaggturnering (2026-07-30) -- flatt felt, individuelt slagbudsjett.
|
||
// Flest fullførte hull vinner (ran_out=true, gikk tom for slag).
|
||
if (round.play_format === "flag") {
|
||
const results = result.flag_results ?? {}
|
||
const rows = Object.entries(results)
|
||
.map(([id, r]) => ({ id, ...r }))
|
||
.sort((a, b) => b.holes_completed - a.holes_completed)
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Flaggturnering</span>
|
||
{rows.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Venter på at handicap beregnes for minst én spiller.</p>
|
||
) : (
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map((r) => (
|
||
<li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{r.label}</span>
|
||
<span className="flex flex-col items-end">
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">
|
||
{r.holes_completed} hull
|
||
</span>
|
||
<span className="text-xs tabular-nums text-muted-foreground">
|
||
{r.ran_out
|
||
? "gikk tom for slag"
|
||
: `${r.strokes_remaining} slag igjen`}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Shamble/Money Ball (2026-07-30) -- flatt felt, ETT lag = hele rundens
|
||
// deltakersett (ingen motstander innad -- flere lag kobles via
|
||
// flight_group_id-leaderboardet). Viser kun lagets samlede sum.
|
||
if (round.play_format === "shamble" || round.play_format === "money_ball") {
|
||
const teamScore = round.play_format === "shamble" ? result.shamble_team_score : result.money_ball_team_score
|
||
return (
|
||
<div className="mb-5 flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-5 text-center">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{FORMAT_LABELS[round.play_format]} -- lagets samlede score
|
||
</span>
|
||
<span className="text-4xl font-extrabold tabular-nums text-foreground">{teamScore ?? "–"}</span>
|
||
{teamScore === null && (
|
||
<p className="text-xs text-muted-foreground">Venter på at alle spillerne registrerer på minst ett hull.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// High-low-high (2026-07-30) -- literal poengsum (IKKE match-play
|
||
// "X UP" -- poeng fordeles 0-2 per hull via to uavhengige duelløer,
|
||
// se handicap_engine.py sin high_low_high_points_for_hole). Samme
|
||
// territorium-bar-visuelle språk som resten av to-sidede formater,
|
||
// men banneret viser "{poeng A}-{poeng B}" i stedet for en "X UP"-tekst.
|
||
if (round.play_format === "high_low_high") {
|
||
if (!result.ready) {
|
||
return (
|
||
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises.
|
||
</div>
|
||
)
|
||
}
|
||
const pa = result.hlh_points_a ?? 0
|
||
const pb = result.hlh_points_b ?? 0
|
||
const holesPlayed = result.match_holes_played ?? 0
|
||
const identityA = sideIdentity(round, players, "a")
|
||
const identityB = sideIdentity(round, players, "b")
|
||
const tone: "a" | "b" | "neutral" = pa > pb ? "a" : pb > pa ? "b" : "neutral"
|
||
const widthA = pa + pb > 0 ? (pa / (pa + pb)) * 100 : 50
|
||
const widthB = 100 - widthA
|
||
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
|
||
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-2">
|
||
<span className="text-sm font-bold text-foreground">High-low-high</span>
|
||
<div
|
||
className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
|
||
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
|
||
>
|
||
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
|
||
<LeadZone identity={identityA} side="a" dominant={tone === "a"} />
|
||
</div>
|
||
<div className="flex h-full items-center justify-center px-2">
|
||
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
|
||
{holesPlayed > 0 ? (
|
||
<>
|
||
<span
|
||
className={cn(
|
||
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
|
||
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||
)}
|
||
>
|
||
{pa}-{pb}
|
||
</span>
|
||
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
|
||
</>
|
||
) : (
|
||
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
|
||
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Scramble mot enkeltspiller (migrasjon 056, 2026-08-05) -- netto
|
||
// slagspill-sammenligning, ikke matchspill -- MÅ avskjæres FØR den
|
||
// generiske to-sidede fallback-koden under, som forutsetter et
|
||
// symmetrisk match-play-format (ville krasjet/vist feil for dette
|
||
// formatet ellers). Gjenbruker samme ScrambleSoloResultView som den
|
||
// frittstående leaderboard-siden (round-leaderboard.tsx), ikke en egen
|
||
// kondensert visning.
|
||
if (round.play_format === "scramble_solo") {
|
||
return (
|
||
<div className="mb-5">
|
||
<ScrambleSoloResultView result={buildScrambleSoloResult(round, result)} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
|
||
if (!result.ready) {
|
||
return (
|
||
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før {FORMAT_LABELS[round.play_format] ?? round.play_format}-status kan vises.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Identitetsbanner (2026-07-29, territorium-bar-versjon -- se leadZoneFraction
|
||
// over). Navn+HCP i hver sone, stor sentrert løpende status flytende oppå
|
||
// begge soner, "Ferdig"-merke når avgjort -- samme visuelle språk (nå) som
|
||
// MatchScorecardGrid (round-scorecard.tsx) og TournamentMatchGrid
|
||
// (session-scorecard.tsx), egen lokal kopi per prosjektets konvensjon.
|
||
const identityA = sideIdentity(round, players, "a")
|
||
const identityB = sideIdentity(round, players, "b")
|
||
const lead = result.match_lead ?? 0
|
||
const holesPlayed = result.match_holes_played ?? 0
|
||
const holesRemaining = result.match_holes_remaining ?? 0
|
||
const isClosed = result.match_is_closed ?? false
|
||
const isDormie = result.match_is_dormie ?? false
|
||
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
|
||
const statusLabel = holesPlayed > 0 ? formatMatchStatus(lead, holesRemaining, isClosed, isDormie) : null
|
||
const totalHoles = holesPlayed + holesRemaining
|
||
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
|
||
const widthB = 100 - widthA
|
||
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
|
||
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-2">
|
||
<span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span>
|
||
{/* CSS Grid, ikke absolutt-posisjonert overlegg (2026-07-29-fiks) --
|
||
en tidligere versjon la statusen som en absolutt sentrert boks OPPÅ
|
||
navnetekst, som kunne overlappe et langt navn på smale skjermer.
|
||
Med grid-kolonner `minmax(0,X fr) auto minmax(0,Y fr)` reserverer
|
||
nettleseren ALLTID nøyaktig midt-kolonnens egen bredde til status-
|
||
boksen -- soneandelen forblir proporsjonal (X:Y), men kan aldri
|
||
overlappe, og lange navn trunkeres i stedet (samme `truncate`-
|
||
mekanisme som ellers). */}
|
||
<div
|
||
className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
|
||
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
|
||
>
|
||
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
|
||
<LeadZone identity={identityA} side="a" dominant={tone === "a"} />
|
||
</div>
|
||
<div className="flex h-full items-center justify-center px-2">
|
||
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
|
||
{statusLabel ? (
|
||
<>
|
||
<span
|
||
className={cn(
|
||
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
|
||
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||
)}
|
||
>
|
||
{statusLabel}
|
||
</span>
|
||
{isClosed ? (
|
||
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
|
||
Ferdig
|
||
</span>
|
||
) : (
|
||
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
|
||
{holesPlayed} hull spilt
|
||
{holesRemaining > 0 && isDormie && " · dormie"}
|
||
</span>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
|
||
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data --
|
||
// tre parallelle vinduer av et EKSISTERENDE to-sidet format, ingen egen
|
||
// lagring (se app/routers/rounds.py sin get_round_nassau_result).
|
||
|
||
type ApiNassauWindow = {
|
||
lead: number
|
||
holes_played: number
|
||
holes_remaining: number
|
||
is_closed: boolean
|
||
is_dormie: boolean
|
||
status_text: string
|
||
}
|
||
type ApiNassauResult = {
|
||
ready: boolean
|
||
front9: ApiNassauWindow | null
|
||
back9: ApiNassauWindow | null
|
||
overall: ApiNassauWindow | null
|
||
}
|
||
|
||
function NassauWindowCard({ label, window }: { label: string; window: ApiNassauWindow | null }) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-background px-3 py-3">
|
||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||
{window && window.holes_played > 0 ? (
|
||
<>
|
||
<span className="text-lg font-extrabold tabular-nums text-foreground">{window.status_text}</span>
|
||
<span className="text-[11px] text-muted-foreground">{window.holes_played} hull spilt</span>
|
||
</>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground">–</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) {
|
||
const [result, setResult] = useState<ApiNassauResult | null>(null)
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/format-result/nassau`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: ApiNassauResult | null) => {
|
||
if (!cancelled) setResult(data)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, formatResultRefreshTick])
|
||
|
||
if (!result?.ready) return null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Nassau</span>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<NassauWindowCard label="Ut (1-9)" window={result.front9} />
|
||
<NassauWindowCard label="Inn (10-18)" window={result.back9} />
|
||
<NassauWindowCard label="Totalt" window={result.overall} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --------
|
||
// Selvstendig, samme "egen fetch + refreshTick"-mønster som NassauPanel over.
|
||
// Rendres kun i Administrer-fanen (samme sted som leaderboard-forhåndsvisning/
|
||
// Nassau) -- kun synlig for eier/medspiller (samme tilgang som resten av
|
||
// Score-siden), ikke tilskuere (de har sin egen /watch/[id]-visning, se
|
||
// egen komponent der).
|
||
|
||
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
|
||
|
||
type ApiFlagMap = { visible_to_all: boolean; flags: FlagMapEntry[] }
|
||
|
||
function FlagMapSection({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) {
|
||
const [data, setData] = useState<ApiFlagMap | null>(null)
|
||
const [expanded, setExpanded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/flag-map`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((json: { visible_to_all: boolean; flags: Array<Record<string, unknown>> } | null) => {
|
||
if (cancelled || !json) return
|
||
setData({
|
||
visible_to_all: json.visible_to_all,
|
||
flags: json.flags.map((f) => ({
|
||
participantId: f.participant_id as string,
|
||
displayName: f.display_name as string,
|
||
lap: f.lap as number,
|
||
holeNumber: f.hole_number as number,
|
||
lat: f.lat as number,
|
||
lng: f.lng as number,
|
||
onGreen: f.on_green as boolean,
|
||
distanceToPinCm: f.distance_to_pin_cm as number | null,
|
||
})),
|
||
})
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, formatResultRefreshTick])
|
||
|
||
if (!data) return null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
className="flex min-h-11 items-center justify-between gap-2 text-left"
|
||
aria-expanded={expanded}
|
||
>
|
||
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
|
||
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
|
||
</button>
|
||
{expanded && (
|
||
<div className="h-80 overflow-hidden rounded-xl border border-border">
|
||
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) -----------------
|
||
// Tre uavhengige kategorier (først på green/nærmest hull/først i hull),
|
||
// manuelt registrert av den som fører score -- ikke GPS/live-sporet. Én
|
||
// spiller kan vinne flere/alle kategorier på samme hull (sistnevnte utløser
|
||
// evt. sweep-bonusen, se compute_bbb). Klikk på en allerede valgt spiller
|
||
// fjerner valget (samme "toggle av/på"-mønster som resten av appen).
|
||
|
||
function BBBCategoryRow({
|
||
label,
|
||
players,
|
||
selectedId,
|
||
onSelect,
|
||
}: {
|
||
label: string
|
||
players: Player[]
|
||
selectedId: string | null
|
||
onSelect: (id: string | null) => void
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{players.map((p) => {
|
||
const active = selectedId === p.id
|
||
return (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
onClick={() => onSelect(active ? null : p.id)}
|
||
aria-pressed={active}
|
||
className={cn(
|
||
"min-h-11 rounded-xl border px-3 text-sm font-semibold transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BBBHolePicker({
|
||
players,
|
||
holeNumber,
|
||
selection,
|
||
onSet,
|
||
}: {
|
||
players: Player[]
|
||
holeNumber: number
|
||
selection: ApiBBBHole | null
|
||
onSet: (holeNumber: number, body: Record<string, string | null>) => Promise<void>
|
||
}) {
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- hull {holeNumber}</span>
|
||
<BBBCategoryRow
|
||
label="Bingo (først på green)"
|
||
players={players}
|
||
selectedId={selection?.bingo_participant_id ?? null}
|
||
onSelect={(id) => void onSet(holeNumber, { bingo_participant_id: id })}
|
||
/>
|
||
<BBBCategoryRow
|
||
label="Bango (nærmest hull, når alle er på green)"
|
||
players={players}
|
||
selectedId={selection?.bango_participant_id ?? null}
|
||
onSelect={(id) => void onSet(holeNumber, { bango_participant_id: id })}
|
||
/>
|
||
<BBBCategoryRow
|
||
label="Bongo (først i hull)"
|
||
players={players}
|
||
selectedId={selection?.bongo_participant_id ?? null}
|
||
onSelect={(id) => void onSet(holeNumber, { bongo_participant_id: id })}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Money Ball -- lineup_order-tildeling (2026-07-30) ----------------------
|
||
// Fast rotasjonsrekkefølge (0-3), avgjør hvem som er "money ball" på hvilket
|
||
// hull. Gjenbruker eksisterende patchParticipant (samme generiske PATCH som
|
||
// resten av spiller-redigeringen) -- ingen egen fetch-logikk her.
|
||
|
||
function MoneyBallLineupPanel({
|
||
players,
|
||
onPatchParticipant,
|
||
}: {
|
||
players: Player[]
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [error, setError] = useState<string | null>(null)
|
||
const allAssigned = players.length === 4 && players.every((p) => p.lineupOrder !== null)
|
||
|
||
async function assign(playerId: string, order: number) {
|
||
setError(null)
|
||
const res = await onPatchParticipant(playerId, { lineup_order: order })
|
||
if (!res.ok) setError(res.message)
|
||
}
|
||
|
||
if (allAssigned) return null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<span className="text-sm font-bold text-foreground">Money Ball -- rekkefølge</span>
|
||
<p className="text-sm text-muted-foreground">
|
||
Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull.
|
||
</p>
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
<ul className="flex flex-col gap-2">
|
||
{players.map((p) => (
|
||
<li key={p.id} className="flex items-center justify-between gap-3 rounded-xl border border-border px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{p.name}</span>
|
||
<div className="flex gap-1.5">
|
||
{[0, 1, 2, 3].map((n) => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
onClick={() => void assign(p.id, n)}
|
||
aria-pressed={p.lineupOrder === n}
|
||
className={cn(
|
||
"flex size-9 items-center justify-center rounded-lg border text-sm font-bold transition-colors",
|
||
p.lineupOrder === n
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Player tabs -----------------------------------------------------------
|
||
|
||
// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
|
||
// Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv
|
||
// spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/
|
||
// statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar
|
||
// underseksjon. Skalerer til mange spillere uten fane-scroll.
|
||
|
||
function PlayerList({
|
||
roundId,
|
||
players,
|
||
sides,
|
||
holesByParticipant,
|
||
activePlayerId,
|
||
onSelect,
|
||
onRemove,
|
||
onAdd,
|
||
addOpen,
|
||
readOnly,
|
||
canManage,
|
||
editingPlayerId,
|
||
onToggleEdit,
|
||
onPatchParticipant,
|
||
onSendScorecard,
|
||
onAddGuest,
|
||
onAddSearched,
|
||
}: {
|
||
roundId: string
|
||
players: Player[]
|
||
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
|
||
// tildelingen skjer i SidesPanel, ikke her.
|
||
sides: ApiSide[]
|
||
// ADR-065 -- kun brukt til "Ferdig · N hull"-tellingen i
|
||
// ParticipantCompletionActions.
|
||
holesByParticipant: Record<string, ApiHole[]>
|
||
activePlayerId: string
|
||
onSelect: (id: string) => void
|
||
onRemove: (id: string) => void
|
||
onAdd: () => void
|
||
addOpen: boolean
|
||
readOnly: boolean
|
||
// Kun runde-eieren kan legge til/fjerne/redigere medspillere (ADR-036
|
||
// fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt
|
||
// VELGE kort for å registrere score for hele flighten.
|
||
canManage: boolean
|
||
editingPlayerId: string | null
|
||
onToggleEdit: (id: string) => void
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-3" role="tablist" aria-label="Velg spiller">
|
||
{players.map((player) => {
|
||
const active = player.id === activePlayerId
|
||
const editing = editingPlayerId === player.id
|
||
const canRemove = !player.isSelf && !readOnly && canManage
|
||
// ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er
|
||
// fortsatt eier-only OG sperret etter fullføring. Men enhver
|
||
// lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal
|
||
// kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status
|
||
// -- dekket av en RESTRIKTERT variant av samme panel, kun
|
||
// meningsfullt for en spiller med egen konto (userId).
|
||
const canEditFull = canManage && !readOnly
|
||
const canEditRestricted = (canManage || player.isSelf) && player.userId !== null
|
||
const canEdit = canEditFull || canEditRestricted
|
||
return (
|
||
<div
|
||
key={player.id}
|
||
className={cn(
|
||
"overflow-hidden rounded-2xl border transition-colors",
|
||
active ? "border-primary" : "border-border",
|
||
)}
|
||
>
|
||
<div className={cn("flex items-stretch gap-1", active ? "bg-primary/10" : "bg-card")}>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => onSelect(player.id)}
|
||
className="flex min-h-[68px] flex-1 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-xl px-4 py-2 text-left transition-colors hover:bg-accent/50"
|
||
>
|
||
{/* Fast kort-høyde uansett innhold (2026-07-26, rapportert av
|
||
bruker) -- begge rader er ÉN linje med trunkering (ikke
|
||
wrap), slik at kort med ulikt antall merker/lange
|
||
navn/utslag likevel blir like høye, og listen samlet tar
|
||
minst mulig plass over score-tastaturet lenger ned. */}
|
||
<span className="flex w-full min-w-0 items-center gap-2">
|
||
<span className="min-w-0 truncate text-base font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
{player.isOwner && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
Eier
|
||
</Badge>
|
||
)}
|
||
{player.roundSideId && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
{(() => {
|
||
const idx = sides.findIndex((s) => s.id === player.roundSideId)
|
||
const side = sides[idx]
|
||
return side?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
})()}
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<span className="w-full truncate text-sm text-muted-foreground">
|
||
Utslag: <span className="font-semibold text-foreground">{player.teeName}</span>
|
||
{" · "}
|
||
HCP:{" "}
|
||
<span className="font-semibold tabular-nums text-foreground">
|
||
{player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
|
||
</span>
|
||
{/* Tildelte slag (course handicap) -- KUN vist når HCP
|
||
faktisk spores for spilleren (samme betingelse som
|
||
"spilles med hcp" -- er den null, spores ikke HCP her). */}
|
||
{player.courseHandicap !== null && (
|
||
<>
|
||
{" · "}
|
||
Tildelte slag: <span className="font-semibold tabular-nums text-foreground">{player.courseHandicap}</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<div className="flex shrink-0 flex-col items-center justify-center gap-1 py-1.5 pr-1.5">
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleEdit(player.id)}
|
||
aria-expanded={editing}
|
||
aria-label={`Rediger ${player.name} for denne runden`}
|
||
className={cn(
|
||
"flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-xl px-2.5 text-sm font-semibold transition-colors",
|
||
editing ? "bg-primary text-primary-foreground" : "text-foreground hover:bg-accent",
|
||
)}
|
||
>
|
||
<Settings2 aria-hidden="true" className="size-4" />
|
||
<span className="hidden sm:inline">Rediger</span>
|
||
</button>
|
||
)}
|
||
{canRemove && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(player.id)}
|
||
aria-label={`Fjern ${player.name}`}
|
||
className="flex min-h-11 min-w-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
|
||
>
|
||
<X aria-hidden="true" className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ParticipantCompletionActions
|
||
player={player}
|
||
holesPlayed={holesByParticipant[player.id]?.filter((h) => h.played).length ?? 0}
|
||
readOnly={readOnly}
|
||
onPatch={onPatchParticipant}
|
||
onSendScorecard={onSendScorecard}
|
||
/>
|
||
|
||
{editing && canEdit && (
|
||
<EditParticipantPanel
|
||
roundId={roundId}
|
||
player={player}
|
||
restricted={!canEditFull}
|
||
onPatch={onPatchParticipant}
|
||
onClose={() => onToggleEdit(player.id)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{!readOnly && canManage && (
|
||
addOpen ? (
|
||
<AddParticipantForm onAddGuest={onAddGuest} onAddSearched={onAddSearched} onCancel={onAdd} />
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
aria-label="Legg til medspiller"
|
||
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-dashed border-border bg-card text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<Plus aria-hidden="true" className="size-5" />
|
||
Medspiller
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Legg til medspiller: søk etter ekte bruker, eller gjest uten konto ----
|
||
// (ADR-036 fase 3-utvidelsen, 2026-07-26 -- "+ Gjest" søkte tidligere ikke
|
||
// etter spillere i det hele tatt, kun et rent tekstfelt.)
|
||
|
||
function AddParticipantForm({
|
||
onAddGuest,
|
||
onAddSearched,
|
||
onCancel,
|
||
}: {
|
||
onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||
onCancel: () => void
|
||
}) {
|
||
const [mode, setMode] = useState<"search" | "guest">("search")
|
||
const [query, setQuery] = useState("")
|
||
const [results, setResults] = useState<PersonMatch[]>([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [adding, setAdding] = useState(false)
|
||
const [searchError, setSearchError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
const trimmed = query.trim()
|
||
if (trimmed.length < 2) {
|
||
setResults([])
|
||
setSearching(false)
|
||
return
|
||
}
|
||
setSearching(true)
|
||
const handle = setTimeout(() => {
|
||
fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: PersonMatch[]) => setResults(data))
|
||
.catch(() => setResults([]))
|
||
.finally(() => setSearching(false))
|
||
}, 250)
|
||
return () => clearTimeout(handle)
|
||
}, [query])
|
||
|
||
async function handlePick(person: PersonMatch) {
|
||
setAdding(true)
|
||
setSearchError(null)
|
||
const errorMessage = await onAddSearched(person.id, "strokes_only")
|
||
setAdding(false)
|
||
if (errorMessage) setSearchError(errorMessage)
|
||
}
|
||
|
||
if (mode === "guest") {
|
||
return <AddGuestForm initialName={query} onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
||
}
|
||
|
||
return (
|
||
<div className="mt-3 flex flex-col gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="participant-search" className="text-base font-semibold">
|
||
Søk etter medspiller
|
||
</Label>
|
||
<div className="relative">
|
||
<Search aria-hidden="true" className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="participant-search"
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Navn på medspilleren"
|
||
className="h-12 rounded-2xl pl-11 text-base"
|
||
/>
|
||
</div>
|
||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||
<p className="text-sm text-muted-foreground">Skriv minst 2 tegn for å søke.</p>
|
||
)}
|
||
</div>
|
||
|
||
{searchError && (
|
||
<p role="alert" className="text-sm font-medium text-destructive">
|
||
{searchError}
|
||
</p>
|
||
)}
|
||
|
||
{results.length > 0 && (
|
||
<ul className="flex flex-col gap-2">
|
||
{results.map((person) => (
|
||
<li key={person.id}>
|
||
<button
|
||
type="button"
|
||
disabled={adding}
|
||
onClick={() => handlePick(person)}
|
||
className="flex min-h-14 w-full items-center gap-3 rounded-2xl border border-border bg-card px-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground"
|
||
>
|
||
{person.first_name[0]}
|
||
{person.last_name[0]}
|
||
</span>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="truncate text-base font-bold text-foreground">
|
||
{person.first_name} {person.last_name}
|
||
</span>
|
||
<span className="truncate text-sm text-muted-foreground">
|
||
{person.home_club}
|
||
{person.home_club && person.handicap_index !== null && " · "}
|
||
{person.handicap_index !== null && `HCP ${person.handicap_index.toFixed(1).replace(".", ",")}`}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{!searching && query.trim().length >= 2 && results.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">Fant ingen med det navnet.</p>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode("guest")}
|
||
className="flex min-h-11 items-center text-sm font-semibold text-foreground underline-offset-2 hover:underline"
|
||
>
|
||
Legg til uten konto (gjest)
|
||
</button>
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-2xl px-5 text-base font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
|
||
// Navnesplitt/e-post/"gjenkjenn gjest"-utvidelse 2026-08-03 -- se
|
||
// CHANGELOG.md for full bakgrunn (retroaktiv e-post-kobling, rundeoppsummer-
|
||
// ing per e-post ved fullføring).
|
||
|
||
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
|
||
// migrasjon 052 sin backfill -- brukt til å autofylle fra søkefeltet.
|
||
function splitName(full: string): { first: string; last: string } {
|
||
const trimmed = full.trim()
|
||
const spaceIdx = trimmed.indexOf(" ")
|
||
if (spaceIdx === -1) return { first: trimmed, last: "" }
|
||
return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() }
|
||
}
|
||
|
||
type KnownGuest = {
|
||
guest_first_name: string
|
||
guest_last_name: string | null
|
||
gender: ApiGender | null
|
||
handicap_index: number | null
|
||
}
|
||
|
||
function AddGuestForm({
|
||
initialName,
|
||
onAdd,
|
||
onCancel,
|
||
onBack,
|
||
}: {
|
||
initialName: string
|
||
onAdd: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onCancel: () => void
|
||
onBack?: () => void
|
||
}) {
|
||
const initialSplit = useMemo(() => splitName(initialName), [initialName])
|
||
const [firstName, setFirstName] = useState(initialSplit.first)
|
||
const [lastName, setLastName] = useState(initialSplit.last)
|
||
const [email, setEmail] = useState("")
|
||
const [gender, setGender] = useState<Gender>("male")
|
||
const [hcp, setHcp] = useState("")
|
||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||
const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(null)
|
||
const [knownGuestDismissed, setKnownGuestDismissed] = useState(false)
|
||
|
||
// "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere
|
||
// registrerte gjester (håndhevet server-side, se find_known_guest i
|
||
// app/routers/rounds.py -- aldri et globalt oppslag).
|
||
useEffect(() => {
|
||
const trimmed = email.trim()
|
||
setKnownGuestDismissed(false)
|
||
if (trimmed.length < 5 || !trimmed.includes("@")) {
|
||
setKnownGuest(null)
|
||
return
|
||
}
|
||
const handle = setTimeout(() => {
|
||
fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: KnownGuest | null) => setKnownGuest(data))
|
||
.catch(() => setKnownGuest(null))
|
||
}, 350)
|
||
return () => clearTimeout(handle)
|
||
}, [email])
|
||
|
||
function useKnownGuest() {
|
||
if (!knownGuest) return
|
||
setFirstName(knownGuest.guest_first_name)
|
||
setLastName(knownGuest.guest_last_name ?? "")
|
||
if (knownGuest.gender) setGender(apiGenderToUi(knownGuest.gender))
|
||
if (knownGuest.handicap_index !== null) setHcp(String(knownGuest.handicap_index).replace(".", ","))
|
||
setKnownGuestDismissed(true)
|
||
}
|
||
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
const trimmedFirst = firstName.trim()
|
||
if (!trimmedFirst) return
|
||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||
onAdd(
|
||
trimmedFirst,
|
||
lastName.trim(),
|
||
email.trim(),
|
||
gender,
|
||
parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null,
|
||
statLevel,
|
||
)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
|
||
{onBack && (
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||
>
|
||
← Søk i stedet
|
||
</button>
|
||
)}
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-first-name" className="text-base font-semibold">
|
||
Fornavn
|
||
</Label>
|
||
<Input
|
||
id="guest-first-name"
|
||
autoFocus
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
placeholder="Fornavn"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-last-name" className="text-base font-semibold">
|
||
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="guest-last-name"
|
||
value={lastName}
|
||
onChange={(e) => setLastName(e.target.value)}
|
||
placeholder="Etternavn"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-email" className="text-base font-semibold">
|
||
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="guest-email"
|
||
type="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="For scorekort og statistikk etter runden"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
{knownGuest && !knownGuestDismissed && (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<p className="text-sm text-foreground">
|
||
Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
|
||
fra en tidligere runde du satte opp.
|
||
</p>
|
||
<Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
|
||
Bruk disse opplysningene
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Kjønn"
|
||
options={[
|
||
{ value: "male", label: "Mann" },
|
||
{ value: "female", label: "Kvinne" },
|
||
{ value: "other", label: "Annet" },
|
||
]}
|
||
value={gender}
|
||
onChange={(v) => setGender(v as Gender)}
|
||
readOnly={false}
|
||
/>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-hcp" className="text-base font-semibold">
|
||
HCP <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="guest-hcp"
|
||
// type="text" (ikke "number") -- komma-desimal ("12,4") blokkeres
|
||
// av native number-inputs i de fleste nettlesere før JS-parsingen
|
||
// (som allerede håndterer komma korrekt) i det hele tatt ser den.
|
||
// 2026-08-19.
|
||
inputMode="decimal"
|
||
value={hcp}
|
||
onChange={(e) => setHcp(e.target.value)}
|
||
placeholder="F.eks. 18"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Statistikk for denne spilleren"
|
||
options={[
|
||
{ value: "strokes_only", label: "Kun slag" },
|
||
{ value: "strokes_and_putts", label: "Slag og putter" },
|
||
{ value: "full", label: "All statistikk" },
|
||
]}
|
||
value={statLevel}
|
||
onChange={(v) => setStatLevel(v as StatLevel)}
|
||
readOnly={false}
|
||
/>
|
||
|
||
<div className="flex gap-3">
|
||
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
|
||
Legg til
|
||
</Button>
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-12 rounded-2xl px-6 text-base font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
// --- Rediger runde (bane/utslag/antall hull) -------------------------------
|
||
// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI
|
||
// allerede registrerte slag/putter/etc. (backend garanterer dette, se
|
||
// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført.
|
||
|
||
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
||
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
|
||
type ApiPersonalCourseSummary = { id: string; name: string }
|
||
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
|
||
// GolfAPI.io (ADR-064) -- for baner utenfor teeoffs dekning (Tjøme Golfklubb var
|
||
// den konkrete anledningen, bekreftet IKKE i teeoff 2026-08-12).
|
||
type ApiInternationalCourseOption = { course_id: string; course_name: string; num_holes: number; has_gps: boolean }
|
||
type ApiInternationalClub = { club_id: string; club_name: string; city: string | null; country: string | null; courses: ApiInternationalCourseOption[] }
|
||
|
||
// Konverterer til/fra <input type="datetime-local">-verdiformatet
|
||
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
|
||
// tidsstempler (UTC), kun visningen er lokal.
|
||
function toDatetimeLocalValue(iso: string | null): string {
|
||
if (!iso) return ""
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return ""
|
||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||
return local.toISOString().slice(0, 16)
|
||
}
|
||
function fromDatetimeLocalValue(value: string): string | null {
|
||
if (!value) return null
|
||
const d = new Date(value)
|
||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||
}
|
||
|
||
// Samme fulle format-liste/etiketter som new-round.tsx sin opprettelses-
|
||
// veiviser (FORMATS) -- API-verdiene her er RoundUpdate.play_format sine
|
||
// egne (samme streng-sett som RoundCreate), ikke wizardens lokale FormatId.
|
||
const EDITABLE_PLAY_FORMATS: { value: string; label: string }[] = [
|
||
{ value: "stroke", label: "Slagspill" },
|
||
{ value: "stableford", label: "Stableford" },
|
||
{ value: "match", label: "Match" },
|
||
{ value: "skins", label: "Skins" },
|
||
{ value: "fourball", label: "Fourball" },
|
||
{ value: "foursome", label: "Foursome" },
|
||
{ value: "greensome", label: "Greensome" },
|
||
{ value: "scramble_2", label: "Scramble (2)" },
|
||
{ value: "scramble_4", label: "Scramble (4)" },
|
||
{ value: "chapman", label: "Chapman" },
|
||
{ value: "copenhagen", label: "Københavner" },
|
||
{ value: "bbb", label: "Bingo Bango Bongo" },
|
||
{ value: "flag", label: "Flaggturnering" },
|
||
{ value: "shamble", label: "Shamble" },
|
||
{ value: "money_ball", label: "Money Ball" },
|
||
{ value: "high_low_high", label: "High-low-high" },
|
||
]
|
||
|
||
function EditRoundPanel({
|
||
round,
|
||
onPatch,
|
||
onClose,
|
||
}: {
|
||
round: ApiRound
|
||
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onClose: () => void
|
||
}) {
|
||
const isCompleted = round.completed_at !== null
|
||
const [name, setName] = useState(round.name ?? "")
|
||
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
|
||
const [startHole, setStartHole] = useState(String(round.start_hole))
|
||
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
|
||
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
|
||
// ADR-038, utvidet med Stableford (migrasjon 038), utvidet igjen
|
||
// 2026-08-03 til ALLE format (samme sett som new-round.tsx sin
|
||
// opprettelses-veiviser -- var kun blitt hengende igjen på de tre
|
||
// opprinnelige verdiene her, en ren forglemmelse fra ADR-039-rundene,
|
||
// ikke en bevisst begrensning) -- ren metadata, aldri sperret av
|
||
// fullført-status (rører aldri AGS/differensial-matematikken selv).
|
||
const currentEditablePlayFormat = round.play_format
|
||
const [playFormat, setPlayFormat] = useState<string>(currentEditablePlayFormat)
|
||
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
|
||
// av fullført-status (samme begrunnelse som playFormat over).
|
||
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
|
||
round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private",
|
||
)
|
||
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||
const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
|
||
const [saving, setSaving] = useState(false)
|
||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const sameCategories =
|
||
visibleCategories.length === round.visible_categories.length &&
|
||
visibleCategories.every((c) => round.visible_categories.includes(c))
|
||
|
||
const hasMetadataChanges =
|
||
name.trim() !== (round.name ?? "") ||
|
||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
|
||
playFormat !== currentEditablePlayFormat ||
|
||
visibilityMode !== round.visibility_mode ||
|
||
(visibilityMode === "friends" && !sameCategories) ||
|
||
flagMapVisible !== round.flag_map_visible
|
||
|
||
async function saveMetadata() {
|
||
setSaving(true)
|
||
setError(null)
|
||
const body: Record<string, unknown> = {}
|
||
// Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor
|
||
// alltid med når feltet faktisk er endret, aldri utelatt for å tømme.
|
||
if (name.trim() !== (round.name ?? "")) body.name = name.trim()
|
||
if (!isCompleted) {
|
||
if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
|
||
if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
|
||
}
|
||
if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt)
|
||
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
|
||
body.completed_at = fromDatetimeLocalValue(completedAt)
|
||
}
|
||
if (playFormat !== currentEditablePlayFormat) body.play_format = playFormat
|
||
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||
if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
|
||
const result = await onPatch(body)
|
||
if (!result.ok) setError(result.message)
|
||
setSaving(false)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">Rediger runde</span>
|
||
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-lg text-muted-foreground" aria-label="Lukk">
|
||
<X aria-hidden="true" className="size-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-round-name" className="text-sm font-semibold">
|
||
Navn på runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="edit-round-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={round.course_name_snapshot}
|
||
maxLength={200}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Spilleform</span>
|
||
{/* Bytte til et to-sidet/delt-ball-format som ennå ikke har sider
|
||
opprettet, eller et format som trenger egen oppsett (Shamble sin
|
||
best_n, Money Ball sin lineup_order), vises allerede av
|
||
eksisterende "sett opp X først"-meldinger andre steder i
|
||
"Spillere og runde" -- ingen egen validering trengs her. */}
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{EDITABLE_PLAY_FORMATS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setPlayFormat(opt.value)}
|
||
aria-pressed={playFormat === opt.value}
|
||
className={cn(
|
||
"min-h-11 rounded-xl border px-2 py-2 text-sm font-bold leading-tight transition-colors",
|
||
playFormat === opt.value
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
|
||
innsyn (venner/offentligheten), aldri en faktisk lagt-til
|
||
medspiller (som alltid ser runden uansett). */}
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Synlighet</span>
|
||
<div className="flex gap-2">
|
||
{(
|
||
[
|
||
{ value: "private" as const, label: "Privat" },
|
||
{ value: "friends" as const, label: "Venner" },
|
||
{ value: "public" as const, label: "Offentlig" },
|
||
]
|
||
).map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setVisibilityMode(opt.value)}
|
||
aria-pressed={visibilityMode === opt.value}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
visibilityMode === opt.value
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{visibilityMode === "friends" && (
|
||
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
|
||
<p className="text-xs font-semibold text-muted-foreground">
|
||
Hvilke av dine venne-kategorier skal kunne se runden?
|
||
</p>
|
||
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
|
||
<button
|
||
type="button"
|
||
onClick={() => setVisibleCategories([])}
|
||
aria-pressed={visibleCategories.length === 0}
|
||
className={cn(
|
||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||
visibleCategories.length === 0
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
Ingen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setVisibleCategories(VISIBILITY_CATEGORY_OPTIONS.map((c) => c.code))}
|
||
aria-pressed={visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length}
|
||
className={cn(
|
||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||
visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
Alle
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
|
||
const active = visibleCategories.includes(code)
|
||
return (
|
||
<button
|
||
key={code}
|
||
type="button"
|
||
onClick={() =>
|
||
setVisibleCategories((prev) =>
|
||
active ? prev.filter((c) => c !== code) : [...prev, code],
|
||
)
|
||
}
|
||
aria-pressed={active}
|
||
className={cn(
|
||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{visibleCategories.length === 0 && (
|
||
<p className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-semibold text-destructive">
|
||
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B",
|
||
ADR-067) -- kun relevant for spilleformatet Flaggturnering.
|
||
Standard AV -- ingen ser hverandres flagg før eieren slår
|
||
dette på (samme "skjult til avslørt"-prinsipp som Synlighet
|
||
over). Spilleren ser alltid sitt eget flagg uansett. */}
|
||
{playFormat === "flag" && (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Kartoversikt over flagg</span>
|
||
<div className="flex gap-2">
|
||
{(
|
||
[
|
||
{ value: false, label: "Av" },
|
||
{ value: true, label: "På" },
|
||
]
|
||
).map((opt) => (
|
||
<button
|
||
key={String(opt.value)}
|
||
type="button"
|
||
onClick={() => setFlagMapVisible(opt.value)}
|
||
aria-pressed={flagMapVisible === opt.value}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
flagMapVisible === opt.value
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
Når kartoversikten er PÅ, kan alle spillere se hvor alle andre har plantet flagget. Av som standard.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{!isCompleted && (
|
||
<>
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Antall hull</span>
|
||
<div className="flex gap-2">
|
||
{([9, 18] as const).map((n) => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
onClick={() => setHolesPlanned(n)}
|
||
aria-pressed={holesPlanned === n}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
holesPlanned === n
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n} hull
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-start-hole" className="text-sm font-semibold">
|
||
Starthull
|
||
</Label>
|
||
<select
|
||
id="edit-start-hole"
|
||
value={startHole}
|
||
onChange={(e) => setStartHole(e.target.value)}
|
||
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
|
||
>
|
||
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
|
||
<option key={n} value={String(n)}>
|
||
Hull {n}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-started-at" className="text-sm font-semibold">
|
||
Utslagstid
|
||
</Label>
|
||
<Input
|
||
id="edit-started-at"
|
||
type="datetime-local"
|
||
value={startedAt}
|
||
onChange={(e) => setStartedAt(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
{isCompleted && (
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-completed-at" className="text-sm font-semibold">
|
||
Fullført-tidspunkt
|
||
</Label>
|
||
<Input
|
||
id="edit-completed-at"
|
||
type="datetime-local"
|
||
value={completedAt}
|
||
onChange={(e) => setCompletedAt(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{hasMetadataChanges && (
|
||
<Button type="button" disabled={saving} onClick={saveMetadata} className="h-11 rounded-xl text-sm font-bold">
|
||
{saving ? "Lagrer…" : "Lagre endringer"}
|
||
</Button>
|
||
)}
|
||
|
||
{!isCompleted && (
|
||
<div className="border-t border-border pt-4">
|
||
{showChangeCourse ? (
|
||
<ChangeCourseForm onPatch={onPatch} onCancel={() => setShowChangeCourse(false)} />
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowChangeCourse(true)}
|
||
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-primary"
|
||
>
|
||
<Search aria-hidden="true" className="size-4" />
|
||
Bytt bane
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Rediger utslag+HCP for én deltaker, KUN for denne runden (2026-07-26) -
|
||
|
||
function EditParticipantPanel({
|
||
roundId,
|
||
player,
|
||
restricted,
|
||
onPatch,
|
||
onClose,
|
||
}: {
|
||
roundId: string
|
||
player: Player
|
||
// ADR-038 -- true når viewer IKKE har full redigeringsrett her (en
|
||
// ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er
|
||
// fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre
|
||
// feltene under.
|
||
restricted: boolean
|
||
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onClose: () => void
|
||
}) {
|
||
// En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil
|
||
// -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin
|
||
// egen konto, og disse feltene gir ingen mening (backend avviser dem også
|
||
// eksplisitt, 2026-07-26).
|
||
const isGuest = player.userId === null
|
||
|
||
const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null)
|
||
const [teeName, setTeeName] = useState(player.teeName)
|
||
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
||
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
|
||
const [guestFirstName, setGuestFirstName] = useState(player.guestFirstName ?? player.name)
|
||
const [guestLastName, setGuestLastName] = useState(player.guestLastName ?? "")
|
||
const [gender, setGender] = useState<Gender>(player.gender)
|
||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (restricted) return
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: ApiTeeOption[]) => {
|
||
if (!cancelled) setTeeOptions(data)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setTeeOptions([])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, restricted])
|
||
|
||
// Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da
|
||
// umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
|
||
// bruker er `gender` uendret gjennom hele panelets levetid.
|
||
const apiGender = uiGenderToApi(gender)
|
||
const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender))
|
||
|
||
async function save() {
|
||
setSaving(true)
|
||
setError(null)
|
||
const body: Record<string, unknown> = {}
|
||
if (!restricted) {
|
||
if (teeName !== player.teeName) body.tee_name = teeName
|
||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
|
||
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
||
if (statLevel !== player.statLevel) body.stat_level = statLevel
|
||
if (isGuest) {
|
||
const trimmedFirst = guestFirstName.trim()
|
||
const trimmedLast = guestLastName.trim()
|
||
if (trimmedFirst && trimmedFirst !== (player.guestFirstName ?? "")) body.guest_first_name = trimmedFirst
|
||
const normalizedLast = trimmedLast === "" ? null : trimmedLast
|
||
if (normalizedLast !== (player.guestLastName ?? null)) body.guest_last_name = normalizedLast
|
||
if (gender !== player.gender) body.gender = apiGender
|
||
const trimmedEmail = guestEmail.trim()
|
||
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
||
if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail
|
||
}
|
||
}
|
||
if (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) {
|
||
body.exclude_from_handicap = excludeFromHandicap
|
||
}
|
||
if (Object.keys(body).length === 0) {
|
||
setSaving(false)
|
||
onClose()
|
||
return
|
||
}
|
||
const result = await onPatch(player.id, body)
|
||
setSaving(false)
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
return
|
||
}
|
||
onClose()
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
{restricted && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Runden er fullført, eller du har ikke rett til å endre de andre feltene her -- du kan
|
||
likevel endre om {player.isSelf ? "din egen deltakelse" : "spillerens deltakelse"} skal
|
||
telle mot {player.isSelf ? "ditt" : "spillerens"} faktiske HCP.
|
||
</p>
|
||
)}
|
||
|
||
{!restricted && (
|
||
<>
|
||
{isGuest && (
|
||
<>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-guest-first-name-${player.id}`} className="text-sm font-semibold">
|
||
Fornavn
|
||
</Label>
|
||
<Input
|
||
id={`edit-guest-first-name-${player.id}`}
|
||
value={guestFirstName}
|
||
onChange={(e) => setGuestFirstName(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-guest-last-name-${player.id}`} className="text-sm font-semibold">
|
||
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id={`edit-guest-last-name-${player.id}`}
|
||
value={guestLastName}
|
||
onChange={(e) => setGuestLastName(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Kjønn"
|
||
options={[
|
||
{ value: "male", label: "Mann" },
|
||
{ value: "female", label: "Kvinne" },
|
||
{ value: "other", label: "Annet" },
|
||
]}
|
||
value={gender}
|
||
onChange={(v) => setGender(v as Gender)}
|
||
readOnly={false}
|
||
/>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
|
||
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id={`edit-guest-email-${player.id}`}
|
||
type="email"
|
||
value={guestEmail}
|
||
onChange={(e) => setGuestEmail(e.target.value)}
|
||
placeholder="navn@eksempel.no"
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border" />
|
||
</>
|
||
)}
|
||
|
||
<p className="text-sm text-muted-foreground">
|
||
Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
|
||
</p>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold">
|
||
Utslagssted
|
||
</Label>
|
||
<select
|
||
id={`edit-tee-${player.id}`}
|
||
value={teeName}
|
||
onChange={(e) => setTeeName(e.target.value)}
|
||
disabled={teeOptions === null}
|
||
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60"
|
||
>
|
||
{!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>}
|
||
{availableTees.map((t) => (
|
||
<option key={t.name} value={t.name}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{teeOptions !== null && availableTees.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet på denne banen.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
|
||
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id={`edit-hcp-${player.id}`}
|
||
// type="text" (ikke "number") -- se guest-hcp over, samme
|
||
// begrunnelse. 2026-08-19.
|
||
inputMode="decimal"
|
||
value={hcp}
|
||
onChange={(e) => setHcp(e.target.value)}
|
||
placeholder="F.eks. 18"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Statistikk for denne spilleren"
|
||
options={[
|
||
{ value: "strokes_only", label: "Kun slag" },
|
||
{ value: "strokes_and_putts", label: "Slag og putter" },
|
||
{ value: "full", label: "All statistikk" },
|
||
]}
|
||
value={statLevel}
|
||
onChange={(v) => setStatLevel(v as StatLevel)}
|
||
readOnly={false}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{!isGuest && (
|
||
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={excludeFromHandicap}
|
||
onChange={(e) => setExcludeFromHandicap(e.target.checked)}
|
||
className="mt-1 size-5 shrink-0 accent-primary"
|
||
/>
|
||
<span className="flex flex-col gap-0.5">
|
||
<span className="text-sm font-bold text-foreground">
|
||
Ekskluder fra {player.isSelf ? "mitt" : "spillerens"} faktiske HCP
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
F.eks. ved matchspill, eller andre runder som ikke bør telle med i HCP-grunnlaget.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
<Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl px-5 text-sm font-bold">
|
||
{saving ? "Lagrer…" : "Lagre endringer"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChangeCourseForm({
|
||
onPatch,
|
||
onCancel,
|
||
}: {
|
||
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onCancel: () => void
|
||
}) {
|
||
const [source, setSource] = useState<"teeoff" | "custom" | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// Teeoff-søk
|
||
const [query, setQuery] = useState("")
|
||
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
|
||
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
|
||
const [officialCourses, setOfficialCourses] = useState<ApiOfficialCourseOption[] | null>(null)
|
||
const [selectedOfficialCourse, setSelectedOfficialCourse] = useState<ApiOfficialCourseOption | null>(null)
|
||
|
||
// Egen bane-søk
|
||
const [customQuery, setCustomQuery] = useState("")
|
||
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
|
||
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
|
||
|
||
// GolfAPI-søk (ADR-064) -- for baner utenfor teeoffs dekning. Importerte
|
||
// baner havner i den samme personal_course-katalogen som "egen bane"
|
||
// over, så selve valg-/utslag-/submit-flyten under trenger INGEN endring
|
||
// -- kun et alternativt inngangspunkt til `selectedCustomCourse`.
|
||
const [internationalOpen, setInternationalOpen] = useState(false)
|
||
const [internationalQuery, setInternationalQuery] = useState("")
|
||
const [internationalClubs, setInternationalClubs] = useState<ApiInternationalClub[] | null>(null)
|
||
const [selectedInternationalClub, setSelectedInternationalClub] = useState<ApiInternationalClub | null>(null)
|
||
const [internationalImporting, setInternationalImporting] = useState(false)
|
||
|
||
const [teeName, setTeeName] = useState("")
|
||
|
||
async function searchInternational() {
|
||
if (!internationalQuery.trim()) return
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/personal-courses/international-search?q=${encodeURIComponent(internationalQuery.trim())}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setInternationalClubs(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å søke hos GolfAPI akkurat nå.")
|
||
}
|
||
}
|
||
|
||
async function importInternational(course: ApiInternationalCourseOption) {
|
||
setInternationalImporting(true)
|
||
setError(null)
|
||
try {
|
||
const importRes = await fetch(`/personal-courses/international-import`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ golfapi_course_id: course.course_id }),
|
||
})
|
||
if (!importRes.ok) throw new Error()
|
||
const imported: { id: string } = await importRes.json()
|
||
const detailRes = await fetch(`/personal-courses/${imported.id}`, { credentials: "include" })
|
||
if (!detailRes.ok) throw new Error()
|
||
setSelectedCustomCourse(await detailRes.json())
|
||
} catch {
|
||
setError("Klarte ikke å importere banen fra GolfAPI. Prøv igjen.")
|
||
} finally {
|
||
setInternationalImporting(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (source !== "custom") return
|
||
let cancelled = false
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" })
|
||
if (res.ok && !cancelled) setCustomResults(await res.json())
|
||
} catch {
|
||
// Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
|
||
}
|
||
}, 250)
|
||
return () => {
|
||
cancelled = true
|
||
clearTimeout(timer)
|
||
}
|
||
}, [source, customQuery])
|
||
|
||
async function searchFacilities() {
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setFacilities(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
||
}
|
||
}
|
||
|
||
async function pickFacility(facility: ApiFacility) {
|
||
setSelectedFacility(facility)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
||
setOfficialCourses(detail.courses)
|
||
} catch {
|
||
setError("Klarte ikke å hente baner for dette anlegget.")
|
||
}
|
||
}
|
||
|
||
async function pickCustomCourse(course: ApiPersonalCourseSummary) {
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setSelectedCustomCourse(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å hente banedetaljer.")
|
||
}
|
||
}
|
||
|
||
const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? []
|
||
|
||
async function handleSubmit() {
|
||
if (!teeName) return
|
||
setSubmitting(true)
|
||
setError(null)
|
||
const body =
|
||
source === "teeoff" && selectedFacility && selectedOfficialCourse
|
||
? {
|
||
course_source: "teeoff",
|
||
teeoff_facility_slug: selectedFacility.slug,
|
||
teeoff_course_id: selectedOfficialCourse.teeoff_course_id,
|
||
tee_name: teeName,
|
||
}
|
||
: selectedCustomCourse
|
||
? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName }
|
||
: null
|
||
if (!body) {
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
const result = await onPatch(body)
|
||
setSubmitting(false)
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
return
|
||
}
|
||
onCancel()
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
||
Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.
|
||
</p>
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
{source === null && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setSource("teeoff")} className="h-11 rounded-xl text-sm font-bold">
|
||
Offisiell bane
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => setSource("custom")} className="h-11 rounded-xl text-sm font-bold">
|
||
Egen bane
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{source === "teeoff" && !selectedFacility && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
searchFacilities()
|
||
}
|
||
}}
|
||
placeholder="Søk klubbnavn…"
|
||
className="h-11 flex-1 rounded-xl text-base"
|
||
/>
|
||
<Button type="button" onClick={searchFacilities} className="h-11 shrink-0 rounded-xl text-sm font-bold">
|
||
Søk
|
||
</Button>
|
||
</div>
|
||
{facilities && (
|
||
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||
{facilities.map((f) => (
|
||
<li key={f.slug} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => pickFacility(f)}
|
||
className="flex min-h-11 w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
|
||
>
|
||
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
||
{(f.city || f.county) && (
|
||
<span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{facilities.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedFacility(null)}
|
||
className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground"
|
||
>
|
||
← {selectedFacility.name}
|
||
</button>
|
||
{officialCourses === null ? (
|
||
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||
) : (
|
||
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||
{officialCourses.map((c) => (
|
||
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedOfficialCourse(c)}
|
||
className="flex min-h-11 w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
|
||
>
|
||
{c.name}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{source === "custom" && !selectedCustomCourse && !internationalOpen && (
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
autoFocus
|
||
value={customQuery}
|
||
onChange={(e) => setCustomQuery(e.target.value)}
|
||
placeholder="Søk egendefinert bane…"
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||
{customResults.map((c) => (
|
||
<li key={c.id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => pickCustomCourse(c)}
|
||
className="flex min-h-11 w-full items-center px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
|
||
>
|
||
{c.name}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{customResults.length === 0 && (
|
||
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||
)}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
onClick={() => setInternationalOpen(true)}
|
||
className="flex min-h-11 items-center self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||
>
|
||
Fant ikke banen? Søk internasjonalt (GolfAPI)
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{source === "custom" && !selectedCustomCourse && internationalOpen && (
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setInternationalOpen(false)
|
||
setSelectedInternationalClub(null)
|
||
setInternationalClubs(null)
|
||
}}
|
||
className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground"
|
||
>
|
||
← Egen bane-søk
|
||
</button>
|
||
{!selectedInternationalClub ? (
|
||
<>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
autoFocus
|
||
value={internationalQuery}
|
||
onChange={(e) => setInternationalQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
searchInternational()
|
||
}
|
||
}}
|
||
placeholder="Søk klubbnavn…"
|
||
className="h-11 flex-1 rounded-xl text-base"
|
||
/>
|
||
<Button type="button" onClick={searchInternational} className="h-11 shrink-0 rounded-xl text-sm font-bold">
|
||
Søk
|
||
</Button>
|
||
</div>
|
||
{internationalClubs && (
|
||
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||
{internationalClubs.map((c) => (
|
||
<li key={c.club_id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedInternationalClub(c)}
|
||
className="flex min-h-11 w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
|
||
>
|
||
<span className="text-sm font-semibold text-foreground">{c.club_name}</span>
|
||
{(c.city || c.country) && (
|
||
<span className="text-xs text-muted-foreground">{[c.city, c.country].filter(Boolean).join(", ")}</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{internationalClubs.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
|
||
</ul>
|
||
)}
|
||
</>
|
||
) : (
|
||
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||
{selectedInternationalClub.courses.map((c) => (
|
||
<li key={c.course_id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
disabled={internationalImporting}
|
||
onClick={() => importInternational(c)}
|
||
className="flex min-h-11 w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60 disabled:opacity-50"
|
||
>
|
||
<span className="flex flex-col">
|
||
{c.course_name}
|
||
<span className="text-xs font-normal text-muted-foreground">
|
||
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
|
||
</span>
|
||
</span>
|
||
{internationalImporting && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(selectedOfficialCourse || selectedCustomCourse) && (
|
||
<div className="flex flex-col gap-3">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
|
||
</span>
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Utslag</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{availableTees.map((t) => (
|
||
<button
|
||
key={t.name}
|
||
type="button"
|
||
onClick={() => setTeeName(t.name)}
|
||
aria-pressed={teeName === t.name}
|
||
className={cn(
|
||
"h-11 rounded-lg border px-3 text-sm font-semibold transition-colors",
|
||
teeName === t.name
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{t.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Button type="button" disabled={submitting || !teeName} onClick={handleSubmit} className="h-11 rounded-xl text-sm font-bold">
|
||
{submitting ? "Bytter bane…" : "Bytt bane"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 self-start rounded-xl text-sm font-semibold text-muted-foreground">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, 2026-07-27) ---
|
||
// Spillere = rader (sticky venstre navnekolonne), hull = horisontalt
|
||
// scrollbare kolonner (sticky Ut/Inn/Sum til høyre) -- erstatter den
|
||
// forrige ett-hull-om-gangen-listen. Tapp en kolonneoverskrift for å velge
|
||
// "gjeldende" hull (kun navigasjon), tapp en spillers navnecelle eller en
|
||
// score-celle for å åpne `ScoringWizard` for AKKURAT den (spiller, hull)-
|
||
// kombinasjonen -- samme veiviser som før, bare adressert direkte fra
|
||
// gridet i stedet for via ett enkelt "aktivt hull"-panel.
|
||
// Par/stroke-indeks er samme fysiske hull for alle spillere -- hentes fra
|
||
// HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv
|
||
// spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn.
|
||
|
||
// Sorteringsnøkkel for lagvis gruppering (2026-08-02) -- side A (sides[0])
|
||
// først, side B (sides[1]) deretter, ingen/ukjent side sist. Brukt med en
|
||
// STABIL sort, så original rekkefølge innad i hver gruppe bevares.
|
||
function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
|
||
if (roundSideId === sides[0]?.id) return 0
|
||
if (roundSideId === sides[1]?.id) return 1
|
||
return 2
|
||
}
|
||
|
||
// ADR-065 -- delt mellom PlayerHoleCards (Score-fanen) og PlayerList
|
||
// ("Spillere og runde"-fanen), samme handlinger begge steder. "Avslutt
|
||
// for [navn]"/"Angre" er FLIGHT-STYRT (patchParticipant, ikke eier-only
|
||
// -- se update_participant i rounds.py), skjules når runden selv er
|
||
// fullført (readOnly) siden backend uansett avviser med ALREADY_COMPLETED
|
||
// da. "Send scorekort" fungerer derimot UANSETT fullføringsstatus.
|
||
function ParticipantCompletionActions({
|
||
player,
|
||
holesPlayed,
|
||
readOnly,
|
||
onPatch,
|
||
onSendScorecard,
|
||
}: {
|
||
player: Player
|
||
holesPlayed: number
|
||
readOnly: boolean
|
||
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [busy, setBusy] = useState(false)
|
||
const [busyError, setBusyError] = useState<string | null>(null)
|
||
const [sendState, setSendState] = useState<"idle" | "sending" | "sent" | "error">("idle")
|
||
const [sendError, setSendError] = useState<string | null>(null)
|
||
|
||
async function toggleCompleted(next: boolean) {
|
||
setBusy(true)
|
||
setBusyError(null)
|
||
const result = await onPatch(player.id, { completed: next })
|
||
setBusy(false)
|
||
if (!result.ok) setBusyError(result.message)
|
||
}
|
||
|
||
async function handleSend() {
|
||
setSendState("sending")
|
||
setSendError(null)
|
||
const result = await onSendScorecard(player.id)
|
||
setSendState(result.ok ? "sent" : "error")
|
||
if (!result.ok) setSendError(result.message)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
|
||
{player.completed ? (
|
||
<>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
|
||
<Check aria-hidden="true" className="size-4" />
|
||
Ferdig{holesPlayed > 0 ? ` · ${holesPlayed} hull` : ""}
|
||
</span>
|
||
{!readOnly && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => toggleCompleted(false)}
|
||
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
|
||
>
|
||
Angre
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
!readOnly && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => toggleCompleted(true)}
|
||
className="flex min-h-11 items-center justify-center rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
Avslutt for {player.name}
|
||
</button>
|
||
)
|
||
)}
|
||
<button
|
||
type="button"
|
||
disabled={sendState === "sending"}
|
||
onClick={handleSend}
|
||
className="ml-auto flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
{sendState === "sending" ? "Sender…" : sendState === "sent" ? "Sendt ✓" : "Send scorekort"}
|
||
</button>
|
||
{busyError && (
|
||
<p role="alert" className="w-full text-sm font-medium text-destructive">
|
||
{busyError}
|
||
</p>
|
||
)}
|
||
{sendState === "error" && sendError && (
|
||
<p role="alert" className="w-full text-sm font-medium text-destructive">
|
||
{sendError}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ADR-066-flagg-tillegget (2026-08-14) -- V0-eksport (zip 11), integrert
|
||
// mot samme props-kontrakt som den midlertidige versjonen den erstatter.
|
||
// Ren UI: komponenten eier kun steg-/geo-/inputtilstand og gjør aldri
|
||
// nettverkskall selv -- kalleren gir `onConfirm`, som gjør det faktiske
|
||
// API-kallet og kan AVVISE forsøket (f.eks. hvis registrert scoredata
|
||
// sier spilleren fortsatt er på et annet hull). Avvisning vises som en
|
||
// vedvarende inline-feilmelding, ikke en forsvinnende toast.
|
||
function FlagPlantSheet({
|
||
open,
|
||
onClose,
|
||
expectedHoleNumber,
|
||
expectedLap,
|
||
onConfirm,
|
||
}: {
|
||
open: boolean
|
||
onClose: () => void
|
||
expectedHoleNumber: number
|
||
expectedLap: number
|
||
onConfirm: (result: {
|
||
lat: number
|
||
lng: number
|
||
onGreen: boolean
|
||
distanceToPinCm: number | null
|
||
}) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps")
|
||
const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading")
|
||
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null)
|
||
|
||
// Step 2 "Ja" reveals a dead-end guidance card (no submission).
|
||
const [holedOutYes, setHoledOutYes] = useState(false)
|
||
|
||
// Step 3 answer + distance inputs (kept as strings for clean input UX).
|
||
const [onGreen, setOnGreen] = useState<boolean | null>(null)
|
||
const [meters, setMeters] = useState("")
|
||
const [centimeters, setCentimeters] = useState("")
|
||
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [serverError, setServerError] = useState<string | null>(null)
|
||
const [confirmedSummary, setConfirmedSummary] = useState<{
|
||
onGreen: boolean
|
||
distanceToPinCm: number | null
|
||
} | null>(null)
|
||
|
||
// Reset everything each time the sheet is (re)opened.
|
||
useEffect(() => {
|
||
if (!open) return
|
||
setStep("gps")
|
||
setGeoStatus("loading")
|
||
setPosition(null)
|
||
setHoledOutYes(false)
|
||
setOnGreen(null)
|
||
setMeters("")
|
||
setCentimeters("")
|
||
setSubmitting(false)
|
||
setServerError(null)
|
||
setConfirmedSummary(null)
|
||
}, [open])
|
||
|
||
// Step 1 — automatic GPS acquisition. Runs on entering the gps step.
|
||
const geoRequested = useRef(false)
|
||
useEffect(() => {
|
||
if (!open || step !== "gps") {
|
||
geoRequested.current = false
|
||
return
|
||
}
|
||
if (geoRequested.current) return
|
||
geoRequested.current = true
|
||
|
||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||
setGeoStatus("error")
|
||
return
|
||
}
|
||
setGeoStatus("loading")
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||
setStep("holedOut")
|
||
},
|
||
() => setGeoStatus("error"),
|
||
{ enableHighAccuracy: true, timeout: 10000 },
|
||
)
|
||
}, [open, step])
|
||
|
||
function retryGeo() {
|
||
geoRequested.current = false
|
||
setGeoStatus("loading")
|
||
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||
setGeoStatus("error")
|
||
return
|
||
}
|
||
geoRequested.current = true
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||
setStep("holedOut")
|
||
},
|
||
() => setGeoStatus("error"),
|
||
{ enableHighAccuracy: true, timeout: 10000 },
|
||
)
|
||
}
|
||
|
||
if (!open) return null
|
||
|
||
// ---- validation for the on-green distance --------------------------------
|
||
const metersNum = meters === "" ? null : Number(meters)
|
||
const cmNum = centimeters === "" ? null : Number(centimeters)
|
||
const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0
|
||
const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99
|
||
const greenInputValid = metersValid && cmValid
|
||
|
||
const canConfirm = onGreen === false || (onGreen === true && greenInputValid)
|
||
|
||
async function handleConfirm() {
|
||
if (!position || onGreen === null || submitting) return
|
||
const distanceToPinCm =
|
||
onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null
|
||
|
||
setSubmitting(true)
|
||
setServerError(null)
|
||
try {
|
||
const res = await onConfirm({
|
||
lat: position.lat,
|
||
lng: position.lng,
|
||
onGreen,
|
||
distanceToPinCm,
|
||
})
|
||
if (res.ok) {
|
||
setConfirmedSummary({ onGreen, distanceToPinCm })
|
||
setStep("done")
|
||
} else {
|
||
setServerError(res.message)
|
||
}
|
||
} catch {
|
||
setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.")
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
function goBack() {
|
||
setServerError(null)
|
||
if (step === "green") {
|
||
setOnGreen(null)
|
||
setMeters("")
|
||
setCentimeters("")
|
||
setStep("holedOut")
|
||
}
|
||
}
|
||
|
||
const showBack = step === "green"
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Plant flagget, hull ${expectedHoleNumber}`}
|
||
>
|
||
{/* 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">
|
||
{showBack ? (
|
||
<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="flex items-start gap-2 pt-1.5">
|
||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
|
||
<Flag className="size-5" aria-hidden="true" />
|
||
</span>
|
||
<div>
|
||
<h2 className="text-lg font-bold leading-tight text-balance">Plant flagget</h2>
|
||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||
{`Hull ${expectedHoleNumber} · runde ${expectedLap}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
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="flex-1 overflow-y-auto p-4">
|
||
{/* Step 1 — GPS */}
|
||
{step === "gps" ? (
|
||
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
|
||
{geoStatus === "loading" ? (
|
||
<>
|
||
<div className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
<p className="text-lg font-semibold">Finner posisjonen din …</p>
|
||
<p className="text-base text-muted-foreground text-pretty">
|
||
Stå der du gikk tom for slag mens vi henter GPS-posisjonen.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left">
|
||
<p role="alert" className="text-base text-destructive text-pretty">
|
||
Fant ikke posisjonen din. Sjekk at posisjonstjenester er på for
|
||
TeeCup, og prøv igjen.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={retryGeo}
|
||
className="inline-flex min-h-12 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 shrink-0" aria-hidden="true" />
|
||
Prøv igjen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Step 2 — Holed out on this hole? */}
|
||
{step === "holedOut" ? (
|
||
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
|
||
<h3 className="text-2xl font-bold text-balance">
|
||
{`Hullet du ut på hull ${expectedHoleNumber}?`}
|
||
</h3>
|
||
<p className="text-base text-muted-foreground text-pretty">
|
||
Er hullet fullført, må scoren føres inn før du kan plante flagget.
|
||
</p>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setHoledOutYes(true)}
|
||
aria-pressed={holedOutYes}
|
||
className={cn(
|
||
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
holedOutYes
|
||
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-muted",
|
||
)}
|
||
>
|
||
Ja
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setHoledOutYes(false)
|
||
setStep("green")
|
||
}}
|
||
className="inline-flex min-h-14 items-center justify-center rounded-xl border-2 border-border bg-card px-6 text-lg font-bold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
Nei
|
||
</button>
|
||
</div>
|
||
|
||
{/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */}
|
||
{holedOutYes ? (
|
||
<div className="flex flex-col gap-4 rounded-2xl border border-brand-orange/40 bg-brand-orange/5 p-4">
|
||
<p role="alert" className="text-base text-foreground text-pretty">
|
||
{`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="inline-flex min-h-14 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"
|
||
>
|
||
Lukk
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Step 3 — On the green? + distance */}
|
||
{step === "green" ? (
|
||
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
|
||
<h3 className="text-2xl font-bold text-balance">Landet du på green?</h3>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOnGreen(true)}
|
||
aria-pressed={onGreen === true}
|
||
className={cn(
|
||
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
onGreen === true
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-muted",
|
||
)}
|
||
>
|
||
Ja
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setOnGreen(false)
|
||
setMeters("")
|
||
setCentimeters("")
|
||
}}
|
||
aria-pressed={onGreen === false}
|
||
className={cn(
|
||
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
onGreen === false
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-muted",
|
||
)}
|
||
>
|
||
Nei
|
||
</button>
|
||
</div>
|
||
|
||
{onGreen === true ? (
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
||
<p className="text-sm text-muted-foreground text-pretty">
|
||
Dette avgjør rekkefølgen mot andre spillere som gikk tom på
|
||
samme hull.
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="flex flex-col gap-1.5">
|
||
<label htmlFor="fp-meters" className="text-base font-semibold">
|
||
Meter
|
||
</label>
|
||
<input
|
||
id="fp-meters"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={meters}
|
||
onChange={(e) => setMeters(e.target.value.replace(/[^0-9]/g, ""))}
|
||
aria-invalid={meters !== "" && !metersValid}
|
||
className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label htmlFor="fp-cm" className="text-base font-semibold">
|
||
Centimeter
|
||
</label>
|
||
<input
|
||
id="fp-cm"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={centimeters}
|
||
onChange={(e) => setCentimeters(e.target.value.replace(/[^0-9]/g, ""))}
|
||
aria-invalid={centimeters !== "" && !cmValid}
|
||
className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{centimeters !== "" && !cmValid ? (
|
||
<p role="alert" className="text-sm font-medium text-destructive">
|
||
Centimeter må være mellom 0 og 99.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{serverError ? (
|
||
<p
|
||
role="alert"
|
||
className="rounded-xl border border-destructive/40 bg-destructive/10 p-3 text-base font-medium text-destructive text-pretty"
|
||
>
|
||
{serverError}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Step 4 — Success */}
|
||
{step === "done" && confirmedSummary ? (
|
||
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
|
||
<span className="flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||
<Check className="size-11" strokeWidth={2.5} aria-hidden="true" />
|
||
</span>
|
||
<h3 className="text-2xl font-bold">Flagget er plantet!</h3>
|
||
<div className="flex flex-col items-center gap-1">
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-base font-semibold">
|
||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||
{`Hull ${expectedHoleNumber}`}
|
||
</span>
|
||
{confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? (
|
||
<span className="text-base text-muted-foreground tabular-nums">
|
||
{`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${
|
||
confirmedSummary.distanceToPinCm % 100
|
||
} cm fra hullet`}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
|
||
{/* Footer — contextual primary action */}
|
||
{step === "green" ? (
|
||
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||
<button
|
||
type="button"
|
||
disabled={!canConfirm || submitting}
|
||
onClick={handleConfirm}
|
||
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 ? (
|
||
<>
|
||
<span className="size-5 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" />
|
||
Sender …
|
||
</>
|
||
) : (
|
||
<>
|
||
<Flag className="size-5 shrink-0" aria-hidden="true" />
|
||
Bekreft plassering
|
||
</>
|
||
)}
|
||
</button>
|
||
</footer>
|
||
) : null}
|
||
|
||
{step === "done" ? (
|
||
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
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"
|
||
>
|
||
Ferdig
|
||
</button>
|
||
</footer>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Flaggturnering: viser plantet-status + "Plant flagget"-knapp for én
|
||
// spiller (2026-08-14). Samme visuelle rad-mønster som
|
||
// ParticipantCompletionActions over.
|
||
function FlagStatusRow({
|
||
player,
|
||
plant,
|
||
currentPosition,
|
||
readOnly,
|
||
onOpenSheet,
|
||
onUnplant,
|
||
}: {
|
||
player: Player
|
||
plant: ApiFlagPlant | null | undefined
|
||
currentPosition: { lap: number; holeNumber: number }
|
||
readOnly: boolean
|
||
onOpenSheet: () => void
|
||
onUnplant: () => Promise<void>
|
||
}) {
|
||
const [busy, setBusy] = useState(false)
|
||
async function handleUnplant() {
|
||
setBusy(true)
|
||
await onUnplant()
|
||
setBusy(false)
|
||
}
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
|
||
{plant ? (
|
||
<>
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
|
||
<Flag aria-hidden="true" className="size-4" />
|
||
Flagg plantet · hull {plant.hole_number}
|
||
{plant.lap > 1 ? ` (runde ${plant.lap})` : ""}
|
||
{plant.on_green && plant.distance_to_pin_cm !== null
|
||
? ` · ${Math.floor(plant.distance_to_pin_cm / 100)}m ${plant.distance_to_pin_cm % 100}cm fra hullet`
|
||
: ""}
|
||
</span>
|
||
{!readOnly && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={handleUnplant}
|
||
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
|
||
>
|
||
Angre
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
!readOnly && (
|
||
<button
|
||
type="button"
|
||
onClick={onOpenSheet}
|
||
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<Flag aria-hidden="true" className="size-4" />
|
||
Plant flagget for {player.name}
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Runde 2+ scoreføring (2026-08-14) -- vises kun når spilleren faktisk har
|
||
// fullført lap 1 (currentPosition.lap > 1) og mangler ikke bygd
|
||
// ScoringWizard-flyt for dette -- flaggformatet er allerede kun
|
||
// hull-granularitet (rå slagtall), en enkel tallinput-liste er
|
||
// proporsjonal, ikke en full ny veiviser-flyt.
|
||
function FlagOverflowPanel({
|
||
player,
|
||
holeOrder,
|
||
overflowHoles,
|
||
currentPosition,
|
||
readOnly,
|
||
onSubmitHole,
|
||
}: {
|
||
player: Player
|
||
holeOrder: number[]
|
||
overflowHoles: ApiFlagOverflowHole[]
|
||
currentPosition: { lap: number; holeNumber: number }
|
||
readOnly: boolean
|
||
onSubmitHole: (lap: number, holeNumber: number, score: number) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [draft, setDraft] = useState<Record<number, string>>({})
|
||
const [error, setError] = useState<string | null>(null)
|
||
if (currentPosition.lap < 2 && overflowHoles.length === 0) return null
|
||
|
||
const lap = currentPosition.lap
|
||
const byHole = new Map(overflowHoles.filter((h) => h.lap === lap).map((h) => [h.hole_number, h]))
|
||
|
||
async function submit(holeNumber: number) {
|
||
const raw = draft[holeNumber]
|
||
const score = parseInt(raw ?? "", 10)
|
||
if (!raw || Number.isNaN(score) || score <= 0) return
|
||
setError(null)
|
||
const result = await onSubmitHole(lap, holeNumber, score)
|
||
if (!result.ok) setError(result.message)
|
||
else setDraft((prev) => ({ ...prev, [holeNumber]: "" }))
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 border-t border-border px-4 py-3">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{player.name} spiller runde {lap} -- før inn slag per hull
|
||
</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{holeOrder.map((h) => {
|
||
const existing = byHole.get(h)
|
||
return (
|
||
<div key={h} className="flex items-center gap-1.5">
|
||
<span className="text-xs font-semibold text-muted-foreground">Hull {h}</span>
|
||
{existing?.played ? (
|
||
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary tabular-nums">
|
||
{existing.score}
|
||
</span>
|
||
) : !readOnly ? (
|
||
<Input
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
max={20}
|
||
value={draft[h] ?? ""}
|
||
onChange={(e) => setDraft((prev) => ({ ...prev, [h]: e.target.value }))}
|
||
onBlur={() => submit(h)}
|
||
className="h-9 w-14 text-center"
|
||
/>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground">–</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{error && <p role="alert" className="text-sm font-medium text-destructive">{error}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
|
||
if (!list || numbers.length === 0) return null
|
||
const played = numbers
|
||
.map((n) => list.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
|
||
if (played.length === 0) return null
|
||
return {
|
||
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
|
||
par: played.reduce((sum, h) => sum + h.par, 0),
|
||
}
|
||
}
|
||
|
||
// Hull-fokusert kortvisning for Score-fanen (2026-08-03, V0-eksport zip 27
|
||
// portert manuelt mot ekte data -- se teecup-scorekort-og-entry-spec.md §1
|
||
// og brukerens eksplisitte "gi V0 friere spillerom"-tilbakemelding). Viser
|
||
// KUN aktivt hull, spillere som stablede kort i stedet for et bredt grid --
|
||
// løser at ScorecardGrid tar for stor plass i bredden på mobil. INGEN
|
||
// informasjon fjernes: alt som var synlig i gridet finnes fortsatt her
|
||
// (HCP/Ut/Inn/Sum/netto/mottatte slag/vunnet hull/ikke-tellende partner),
|
||
// resten (alle 18 hull samtidig) er ScorecardGrid selv, uendret, bak en
|
||
// "Vis hele scorekortet"-bryter (se render-stedet i hoved-komponenten).
|
||
// Trykk åpner SAMME eksisterende fullskjerm-veiviser som før (onOpenEntry)
|
||
// -- selve registreringsvinduet er bevisst IKKE rørt av denne rundens
|
||
// V0-prompt.
|
||
function PlayerHoleCards({
|
||
roundId,
|
||
players,
|
||
holesByParticipant,
|
||
holeOrder,
|
||
holesPlanned,
|
||
currentHole,
|
||
readOnly,
|
||
sides,
|
||
isTwoSided,
|
||
formatResult,
|
||
ownBagClubs,
|
||
onSelectHole,
|
||
onPrevHole,
|
||
onNextHole,
|
||
onOpenEntry,
|
||
onPatchParticipant,
|
||
onSendScorecard,
|
||
playFormat,
|
||
flagPlantByParticipant,
|
||
flagOverflowByParticipant,
|
||
onPlantFlag,
|
||
onUnplantFlag,
|
||
onSubmitFlagOverflowHole,
|
||
}: {
|
||
roundId: string
|
||
players: Player[]
|
||
holesByParticipant: Record<string, ApiHole[]>
|
||
holeOrder: number[]
|
||
holesPlanned: number
|
||
currentHole: number
|
||
readOnly: boolean
|
||
sides: ApiSide[]
|
||
isTwoSided: boolean
|
||
formatResult: ApiFormatResult | null
|
||
ownBagClubs: string[]
|
||
onSelectHole: (holeNumber: number) => void
|
||
onPrevHole: () => void
|
||
onNextHole: () => void
|
||
onOpenEntry: (playerId: string, holeNumber: number) => void
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
playFormat: string
|
||
flagPlantByParticipant: Record<string, ApiFlagPlant | null>
|
||
flagOverflowByParticipant: Record<string, ApiFlagOverflowHole[]>
|
||
onPlantFlag: (
|
||
participantId: string,
|
||
body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null },
|
||
) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onUnplantFlag: (participantId: string) => Promise<void>
|
||
onSubmitFlagOverflowHole: (
|
||
participantId: string, lap: number, holeNumber: number, score: number,
|
||
) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [flagSheetParticipantId, setFlagSheetParticipantId] = useState<string | null>(null)
|
||
// ADR-079 (2026-08-16): hvilken spillers fulle hull-historikk som vises
|
||
// akkurat nå, om noen -- historikk-oppsummeringen (HoleHistoryPanel)
|
||
// sitter her i kortet (skjermen FØR slagvinduet), åpner denne fullskjerm-
|
||
// visningen ved klikk i stedet for å ekspandere inline.
|
||
const [historyPlayerId, setHistoryPlayerId] = useState<string | null>(null)
|
||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||
for (const list of Object.values(holesByParticipant)) {
|
||
for (const h of list) {
|
||
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
|
||
}
|
||
}
|
||
const meta = holeMeta[currentHole]
|
||
|
||
const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
|
||
const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
|
||
const formatHole = showMatchColors ? formatHolesByNumber.get(currentHole) : undefined
|
||
function sideOf(playerId: string): "a" | "b" | null {
|
||
const p = players.find((x) => x.id === playerId)
|
||
if (!p?.roundSideId) return null
|
||
return p.roundSideId === sides[0]?.id ? "a" : "b"
|
||
}
|
||
|
||
const sortedPlayers =
|
||
sides.length === 2
|
||
? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
|
||
: players
|
||
|
||
const allRegisteredByHole: Record<number, boolean> = {}
|
||
for (const n of holeOrder) {
|
||
allRegisteredByHole[n] = players.every((p) => {
|
||
// ADR-065 -- en avsluttet spiller blokkerer aldri "registrert"-status
|
||
// for et hull de aldri kommer til å spille.
|
||
if (p.completed) return true
|
||
const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
|
||
return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Hull-navigator: gjenbruker samme goPrev/goNext-logikk (wrap-around)
|
||
som resten av siden allerede har, pluss en hurtig-hopp-stripe --
|
||
erstatter behovet for å scrolle et bredt grid for å bytte hull. */}
|
||
<div className="rounded-2xl border border-border bg-card p-3">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onPrevHole}
|
||
aria-label="Forrige hull"
|
||
className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<ChevronLeft aria-hidden="true" className="size-5" />
|
||
Forrige
|
||
</button>
|
||
<div className="flex min-w-[7.5rem] flex-col items-center px-1 text-center">
|
||
<span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
|
||
Hull {currentHole}
|
||
</span>
|
||
<span className="mt-1 text-xs font-semibold text-muted-foreground tabular-nums">
|
||
{meta ? `Par ${meta.par} · Indeks ${meta.index}` : " "}
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onNextHole}
|
||
aria-label="Neste hull"
|
||
className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
Neste
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</button>
|
||
</div>
|
||
<nav aria-label="Hopp til hull" className="-mx-1 mt-3 overflow-x-auto px-1">
|
||
<ul className="flex gap-1.5 pb-0.5">
|
||
{holeOrder.map((n) => {
|
||
const active = n === currentHole
|
||
const registered = allRegisteredByHole[n]
|
||
return (
|
||
<li key={n}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectHole(n)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Hull ${n}${registered ? ", registrert" : ""}`}
|
||
className={cn(
|
||
// size-11 (44px), ikke V0-eksportens size-9 -- CLAUDE.md sitt
|
||
// ufravikelige 44px-trykkgulv gjelder ALT trykkbart, også en
|
||
// kompakt hurtig-hopp-stripe.
|
||
"flex size-11 shrink-0 items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: registered
|
||
? "border-primary/40 bg-primary/10 text-foreground"
|
||
: "border-border bg-background text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Avstand til grønn (ADR-064, utvidet til ekte teeoff-baner i
|
||
migrasjon 079) -- kun for baner med koordinatdata; ingen synlig
|
||
rad i det hele tatt for andre baner (HoleTargetDistance returnerer
|
||
null da). Full bredde, samme plassering brukeren pekte ut (rett
|
||
under hull-navigatoren, over spillerkortene) -- ikke den smale
|
||
kompaktvarianten. */}
|
||
<HoleTargetDistance baseUrl={`/rounds/${roundId}`} holeNumber={currentHole} size="full" />
|
||
|
||
{isTwoSided && sides.length === 2 && (
|
||
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
|
||
<span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-primary" />
|
||
<span className="font-bold text-foreground">{sides[0]?.label?.trim() || "Side A"}</span>
|
||
<span className="mx-1 text-muted-foreground">mot</span>
|
||
<span className="font-bold text-foreground">{sides[1]?.label?.trim() || "Side B"}</span>
|
||
<span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-brand-orange" />
|
||
</div>
|
||
)}
|
||
|
||
<ul className="flex flex-col gap-3">
|
||
{sortedPlayers.map((player) => {
|
||
const list = holesByParticipant[player.id]
|
||
const apiHole = list?.find((h) => h.hole_number === currentHole) ?? null
|
||
const hasScore = Boolean(apiHole?.played) && (apiHole?.score !== null || apiHole?.picked_up)
|
||
const diff = hasScore && meta && apiHole?.score !== null ? (apiHole!.score as number) - meta.par : null
|
||
const net =
|
||
hasScore && apiHole?.score !== null && apiHole?.strokes_received != null
|
||
? (apiHole!.score as number) - apiHole!.strokes_received
|
||
: null
|
||
const points = net !== null && meta ? stablefordPoints(net, meta.par) : null
|
||
const playerSide = showMatchColors ? sideOf(player.id) : null
|
||
const entry = formatHole?.entries.find((e) => e.id === player.id)
|
||
const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
|
||
const dimmed = Boolean(entry && entry.counted === false)
|
||
|
||
const frontSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n <= 9)) : null
|
||
const backSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n >= 10)) : null
|
||
const totalSum = sumForPlayer(list, holeOrder)
|
||
|
||
return (
|
||
<li key={player.id}>
|
||
<div
|
||
className={cn(
|
||
"overflow-hidden rounded-3xl border bg-card shadow-sm shadow-black/5 transition-colors",
|
||
dimmed ? "border-dashed border-border opacity-60" : "border-border",
|
||
)}
|
||
style={
|
||
playerSide
|
||
? { borderLeftWidth: 6, borderLeftColor: playerSide === "a" ? "var(--primary)" : "var(--brand-orange)" }
|
||
: undefined
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(player.id, currentHole)}
|
||
aria-label={
|
||
hasScore
|
||
? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
|
||
: player.completed
|
||
? `${player.name} har avsluttet runden, hull ${currentHole} ikke spilt`
|
||
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
|
||
}
|
||
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
|
||
>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
{playerSide && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn("size-2.5 shrink-0 rounded-full", playerSide === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
/>
|
||
)}
|
||
<span className="truncate text-base font-bold tracking-tight text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs font-medium text-muted-foreground tabular-nums">
|
||
<span>HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "–"}</span>
|
||
{holesPlanned === 18 ? (
|
||
<>
|
||
<span aria-hidden="true">·</span>
|
||
<span>
|
||
Ut {frontSum ? frontSum.strokes : "–"} · Inn {backSum ? backSum.strokes : "–"} · Sum{" "}
|
||
<span className="font-bold text-foreground">{totalSum ? totalSum.strokes : "–"}</span>
|
||
{totalSum ? ` (${formatToPar(totalSum.strokes - totalSum.par)})` : ""}
|
||
</span>
|
||
</>
|
||
) : (
|
||
totalSum && (
|
||
<>
|
||
<span aria-hidden="true">·</span>
|
||
<span>
|
||
Sum <span className="font-bold text-foreground">{totalSum.strokes}</span> (
|
||
{formatToPar(totalSum.strokes - totalSum.par)})
|
||
</span>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
{dimmed && (
|
||
<span className="text-[11px] font-semibold text-muted-foreground">Teller ikke på dette hullet</span>
|
||
)}
|
||
{won && !dimmed && (
|
||
<span
|
||
className="mt-0.5 inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
|
||
style={{ backgroundColor: playerSide === "b" ? "var(--brand-orange)" : "var(--primary)" }}
|
||
>
|
||
Vant hullet
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 flex-col items-center gap-1">
|
||
{hasScore ? (
|
||
<>
|
||
<ScorecardCell
|
||
value={apiHole?.picked_up ? null : (apiHole!.score as number)}
|
||
diff={diff}
|
||
pickedUp={apiHole?.picked_up}
|
||
size="lg"
|
||
/>
|
||
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
||
{net !== null ? `Netto ${net}` : " "}
|
||
{points !== null ? ` · ${points} p` : ""}
|
||
</span>
|
||
</>
|
||
) : player.completed ? (
|
||
// ADR-065 -- spilleren er individuelt avsluttet, dette
|
||
// hullet blir aldri spilt. Ingen "Registrer"-oppfordring.
|
||
<span className="text-[11px] font-semibold text-muted-foreground">Ferdig</span>
|
||
) : (
|
||
<>
|
||
<span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
|
||
<Plus aria-hidden="true" className="size-5" />
|
||
</span>
|
||
{apiHole?.strokes_received ? (
|
||
<StrokeDots count={apiHole.strokes_received} />
|
||
) : (
|
||
<span className="text-[11px] font-semibold text-primary">Registrer</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</button>
|
||
{!readOnly && (
|
||
<div className="flex justify-end border-t border-border px-4 py-2">
|
||
<ShotMeasurementEntry
|
||
roundId={roundId}
|
||
holeNumber={currentHole}
|
||
owner={{ kind: "participant", id: player.id }}
|
||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||
readOnly={readOnly}
|
||
variant="badge"
|
||
/>
|
||
</div>
|
||
)}
|
||
{/* Personlig hull-historikk (ADR-072, flyttet hit ut av
|
||
slagvinduet ADR-079, 2026-08-16) -- skjermen FØR
|
||
registrering/"Avslutt for {navn}", ikke inni selve
|
||
slagvinduet. Skjuler seg selv når banen er custom eller
|
||
spilleren ikke har historikk ennå. */}
|
||
<div className="px-4 pb-2">
|
||
<HoleHistoryPanel
|
||
url={`/rounds/${roundId}/participants/${player.id}/holes/${currentHole}/history`}
|
||
onOpenFull={() => setHistoryPlayerId(player.id)}
|
||
/>
|
||
</div>
|
||
<ParticipantCompletionActions
|
||
player={player}
|
||
holesPlayed={list?.filter((h) => h.played).length ?? 0}
|
||
readOnly={readOnly}
|
||
onPatch={onPatchParticipant}
|
||
onSendScorecard={onSendScorecard}
|
||
/>
|
||
{playFormat === "flag" && (() => {
|
||
const currentPosition = computeFlagCurrentPosition(
|
||
holeOrder, holesByParticipant[player.id], flagOverflowByParticipant[player.id],
|
||
)
|
||
return (
|
||
<>
|
||
<FlagStatusRow
|
||
player={player}
|
||
plant={flagPlantByParticipant[player.id]}
|
||
currentPosition={currentPosition}
|
||
readOnly={readOnly}
|
||
onOpenSheet={() => setFlagSheetParticipantId(player.id)}
|
||
onUnplant={() => onUnplantFlag(player.id)}
|
||
/>
|
||
<FlagOverflowPanel
|
||
player={player}
|
||
holeOrder={holeOrder}
|
||
overflowHoles={flagOverflowByParticipant[player.id] ?? []}
|
||
currentPosition={currentPosition}
|
||
readOnly={readOnly}
|
||
onSubmitHole={(lap, holeNumber, score) => onSubmitFlagOverflowHole(player.id, lap, holeNumber, score)}
|
||
/>
|
||
{flagSheetParticipantId === player.id && (
|
||
<FlagPlantSheet
|
||
open
|
||
onClose={() => setFlagSheetParticipantId(null)}
|
||
expectedHoleNumber={currentPosition.holeNumber}
|
||
expectedLap={currentPosition.lap}
|
||
onConfirm={async (result) => {
|
||
const res = await onPlantFlag(player.id, {
|
||
lat: result.lat, lng: result.lng,
|
||
hole_number: currentPosition.holeNumber, lap: currentPosition.lap,
|
||
on_green: result.onGreen, distance_to_pin_cm: result.distanceToPinCm,
|
||
})
|
||
return res
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
|
||
{historyPlayerId && (
|
||
<HoleHistoryDetail
|
||
url={`/rounds/${roundId}/participants/${historyPlayerId}/holes/${currentHole}/history`}
|
||
holeNumber={currentHole}
|
||
onClose={() => setHistoryPlayerId(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ScorecardGrid({
|
||
players,
|
||
holesByParticipant,
|
||
holeOrder,
|
||
holesPlanned,
|
||
currentHole,
|
||
readOnly,
|
||
sides,
|
||
isTwoSided,
|
||
formatResult,
|
||
onSelectHole,
|
||
onOpenEntry,
|
||
}: {
|
||
players: Player[]
|
||
holesByParticipant: Record<string, ApiHole[]>
|
||
holeOrder: number[]
|
||
holesPlanned: number
|
||
currentHole: number
|
||
readOnly: boolean
|
||
// Match-aware fargelegging + Stilling-rad (2026-07-29) -- kun for
|
||
// to-sidede individuell-ball-formater (match/fourball), aldri slagspill/
|
||
// skins (ingen sider/lead-begrep der).
|
||
sides: ApiSide[]
|
||
isTwoSided: boolean
|
||
formatResult: ApiFormatResult | null
|
||
onSelectHole: (holeNumber: number) => void
|
||
onOpenEntry: (playerId: string, holeNumber: number) => void
|
||
}) {
|
||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||
for (const list of Object.values(holesByParticipant)) {
|
||
for (const h of list) {
|
||
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
|
||
}
|
||
}
|
||
|
||
// Ut(1-9)/Inn(10-18) er alltid gruppert på FYSISK hullnummer (tradisjonell
|
||
// scorekort-konvensjon), ikke på spillerekkefølgen -- riktig uansett
|
||
// hvilket starthull runden faktisk begynte på. Kun meningsfullt å splitte
|
||
// for en 18-hulls runde; en 9-hulls runde får kun én samlet Sum-kolonne.
|
||
const showSplit = holesPlanned === 18
|
||
const front = holeOrder.filter((n) => n <= 9)
|
||
const back = holeOrder.filter((n) => n >= 10)
|
||
|
||
function sumFor(playerId: string, numbers: number[]): { strokes: number; par: number } | null {
|
||
const list = holesByParticipant[playerId]
|
||
if (!list || numbers.length === 0) return null
|
||
const played = numbers
|
||
.map((n) => list.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
|
||
if (played.length === 0) return null
|
||
return {
|
||
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
|
||
par: played.reduce((sum, h) => sum + h.par, 0),
|
||
}
|
||
}
|
||
|
||
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
|
||
|
||
// Match-aware fargelegging (2026-07-29): entries[].id == round_participant.id
|
||
// for individuell-ball-formater (match/fourball) -- se _build_format_result
|
||
// i app/routers/rounds.py. Kun relevant når formatResult faktisk er klart.
|
||
const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
|
||
const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
|
||
const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map()
|
||
function sideOf(playerId: string): "a" | "b" | null {
|
||
const p = players.find((x) => x.id === playerId)
|
||
if (!p?.roundSideId) return null
|
||
return p.roundSideId === sides[0]?.id ? "a" : "b"
|
||
}
|
||
|
||
// Lagvis gruppering (2026-08-02, "det store grepet") -- et to-sidet
|
||
// format (fourball/match/foursome osv.) skal vise side A samlet, deretter
|
||
// side B samlet, ikke ren innsettingsrekkefølge (A, B, C, D uansett hvem
|
||
// som faktisk er lagkamerater). Stabil sortering bevarer original
|
||
// rekkefølge INNAD i hver gruppe -- kun selve grupperingen endres. Kun
|
||
// meningsfullt når begge sider faktisk finnes; ellers uendret rekkefølge.
|
||
const sortedPlayers =
|
||
sides.length === 2
|
||
? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
|
||
: players
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||
<table className="w-full border-separate border-spacing-0 text-sm">
|
||
<colgroup>
|
||
<col className="w-[130px]" />
|
||
{holeOrder.map((n) => (
|
||
<col key={n} className="w-[52px]" />
|
||
))}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
<col className="w-[60px]" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border">
|
||
<th
|
||
scope="col"
|
||
className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
|
||
>
|
||
Spiller
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const active = n === currentHole
|
||
return (
|
||
<th key={n} scope="col" className="border-l border-border p-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectHole(n)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Velg hull ${n}`}
|
||
className={cn(
|
||
"flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
||
active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
</th>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Ut
|
||
</th>
|
||
)}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Inn
|
||
</th>
|
||
)}
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Sum
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* Hcp- og Par-referanserader (samme mønster som den allerede
|
||
shippede round-scorecard.tsx) -- rene informasjonsrader, ikke
|
||
trykkbare. */}
|
||
<tr className="border-b border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Hcp
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.index ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
<tr className="border-b-2 border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Par
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.par ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{parRowTotal || "–"}
|
||
</td>
|
||
</tr>
|
||
|
||
{sortedPlayers.map((player) => {
|
||
const list = holesByParticipant[player.id]
|
||
const frontSum = showSplit ? sumFor(player.id, front) : null
|
||
const backSum = showSplit ? sumFor(player.id, back) : null
|
||
const totalSum = sumFor(player.id, holeOrder)
|
||
const playerSide = showMatchColors ? sideOf(player.id) : null
|
||
return (
|
||
<tr key={player.id} className="border-b border-border last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(player.id, currentHole)}
|
||
aria-label={`Registrer score for ${player.name} på hull ${currentHole}`}
|
||
className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
{playerSide && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"size-2 shrink-0 rounded-full",
|
||
playerSide === "a" ? "bg-primary" : "bg-brand-orange",
|
||
)}
|
||
/>
|
||
)}
|
||
<span className="truncate text-sm font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<span className="truncate text-xs tabular-nums text-muted-foreground">
|
||
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "–"}
|
||
</span>
|
||
</button>
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const apiHole = list?.find((h) => h.hole_number === n) ?? null
|
||
const meta = holeMeta[n]
|
||
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
|
||
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
|
||
const net =
|
||
hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null
|
||
const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined
|
||
const entry = formatHole?.entries.find((e) => e.id === player.id)
|
||
const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
|
||
const dimmed = Boolean(entry && entry.counted === false)
|
||
return (
|
||
<td key={n} className="border-l border-border p-0 text-center">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(player.id, n)}
|
||
aria-label={
|
||
hasScore
|
||
? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
|
||
: `Registrer ${player.name} sin score på hull ${n}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
|
||
}
|
||
className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
|
||
>
|
||
{showMatchColors ? (
|
||
<MatchScorecardCell
|
||
value={hasScore ? (apiHole!.score as number) : null}
|
||
side={playerSide}
|
||
won={won}
|
||
dimmed={dimmed}
|
||
/>
|
||
) : (
|
||
<ScorecardCell
|
||
value={hasScore ? (apiHole!.score as number) : null}
|
||
diff={diff}
|
||
pickedUp={apiHole?.picked_up}
|
||
/>
|
||
)}
|
||
{hasScore ? (
|
||
net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>
|
||
) : (
|
||
// Mottatte slag vist FØR hullet er fylt ut (2026-07-29,
|
||
// etterspurt av bruker) -- kun når spilleren faktisk
|
||
// mottar minst ett slag her (ingen støy på de andre).
|
||
// Prikker i stedet for "−N"-tekst (2026-07-30).
|
||
apiHole?.strokes_received != null &&
|
||
apiHole.strokes_received > 0 && <StrokeDots count={apiHole.strokes_received} />
|
||
)}
|
||
</button>
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{frontSum ? frontSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{backSum ? backSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center">
|
||
{totalSum ? (
|
||
<span className="flex flex-col items-center leading-tight">
|
||
<span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
|
||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
|
||
{formatToPar(totalSum.strokes - totalSum.par)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
|
||
fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
|
||
lead-poeng per vunnet hull som resten av to-sidede formater
|
||
(denne raden ville da vist en misvisende match-play-tekst
|
||
basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
|
||
vises i stedet i FormatResultPanel over selve gridet --
|
||
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
|
||
vises uendret. Formatet er uansett aldri delt-ball, så denne
|
||
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
|
||
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
|
||
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||
Stilling
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const s = stillingByHole.get(n)
|
||
return (
|
||
<td
|
||
key={n}
|
||
className={cn(
|
||
"border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
|
||
!s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
|
||
)}
|
||
>
|
||
{s?.label ?? "–"}
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Golfscore-språket for en LITEN tabellcelle -- samme eksakte klassifisering
|
||
// og Tailwind-klasser som `ScoreMark`/`classify` i round-scorecard.tsx
|
||
// (bekreftet ved å lese kildekoden der direkte, egen kopi her per
|
||
// prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se
|
||
// DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over
|
||
// par, ren tekst på par, fylt ved 2+ slag fra par.
|
||
// Mottatte slag FØR hullet er fylt ut, vist som prikker i stedet for tekst
|
||
// (2026-07-30, etterspurt av bruker som en kompakt erstatning for "−N") --
|
||
// ren visuell hint, den faktiske informasjonen ligger i knappens eget
|
||
// aria-label (§tilgjengelighet: aldri kun et visuelt symbol uten tekst for
|
||
// noe som faktisk betyr noe).
|
||
function StrokeDots({ count }: { count: number }) {
|
||
return (
|
||
<span className="flex items-center justify-center gap-0.5" aria-hidden="true">
|
||
{Array.from({ length: count }, (_, i) => (
|
||
<span key={i} className="size-1.5 rounded-full bg-info" />
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function ScorecardCell({
|
||
value,
|
||
diff,
|
||
pickedUp,
|
||
size = "sm",
|
||
}: {
|
||
value: number | null
|
||
diff: number | null
|
||
pickedUp?: boolean
|
||
// "lg" brukt av PlayerHoleCards (2026-08-03) -- samme form-/fargespråk,
|
||
// bare skalert opp for den hull-fokuserte kortvisningen.
|
||
size?: "sm" | "lg"
|
||
}) {
|
||
const shared = cn(
|
||
"flex items-center justify-center font-extrabold tabular-nums",
|
||
size === "lg" ? "size-12 text-2xl" : "size-7 text-sm",
|
||
)
|
||
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
|
||
// vises som "PU" i stedet for det (allerede kjente) Net Double Bogey-
|
||
// slagtallet, som ellers ville sett ut som en vilkårlig høy score.
|
||
if (pickedUp) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
shared,
|
||
"rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
|
||
size === "lg" ? "text-sm" : "text-[10px]",
|
||
)}
|
||
>
|
||
PU
|
||
</span>
|
||
)
|
||
}
|
||
if (value === null) return <span className={cn(shared, "text-muted-foreground")}>–</span>
|
||
if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span>
|
||
if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span>
|
||
if (diff === -1) return <span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>{value}</span>
|
||
if (diff === 1) {
|
||
return <span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange")}>{value}</span>
|
||
}
|
||
return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span>
|
||
}
|
||
|
||
// Match-aware variant av ScorecardCell (2026-07-29) -- fargen følger HVEM SOM
|
||
// VANT hullet (grønn=side A, oransje=side B), ikke over/under par, siden
|
||
// det er det som betyr noe i en match. Nedtonet tekst for en fourball-
|
||
// partner sin ikke-tellende score (laveste netto teller for siden).
|
||
function MatchScorecardCell({
|
||
value,
|
||
side,
|
||
won,
|
||
dimmed,
|
||
}: {
|
||
value: number | null
|
||
side: "a" | "b" | null
|
||
won: boolean
|
||
dimmed: boolean
|
||
}) {
|
||
const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
|
||
if (value === null) return <span className={cn(shared, "text-muted-foreground")}>–</span>
|
||
if (won) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
shared,
|
||
"rounded-full",
|
||
side === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
|
||
)}
|
||
>
|
||
{value}
|
||
</span>
|
||
)
|
||
}
|
||
return <span className={cn(shared, dimmed ? "text-muted-foreground/50" : "font-bold text-foreground")}>{value}</span>
|
||
}
|
||
|
||
// --- Statistikknivå-velger ---------------------------------------------------
|
||
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
|
||
// overflødig -- score settes allerede automatisk til "spilt" idet et
|
||
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
|
||
// 2026-07-24 på brukerens eksplisitte bekreftelse.
|
||
|
||
// --- Number picker ---------------------------------------------------------
|
||
|
||
// Golf-term per slagtall relativt til par (2026-07-26, inspirert av en
|
||
// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt --
|
||
// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller
|
||
// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren.
|
||
// --- Delt-ball-scorekort (ADR-039 Beslutning C) -----------------------------
|
||
// Samme grid-idé som ScorecardGrid, men rader = SIDER (ikke spillere) -- kun
|
||
// ett tall per hull (ingen putt/netto-linje, en delt ball har ingen entydig
|
||
// netto-visning per spiller her).
|
||
|
||
function SideScorecardGrid({
|
||
sides,
|
||
players,
|
||
holesBySide,
|
||
holeOrder,
|
||
holesPlanned,
|
||
currentHole,
|
||
readOnly,
|
||
isTwoSided,
|
||
formatResult,
|
||
onSelectHole,
|
||
onOpenEntry,
|
||
}: {
|
||
sides: ApiSide[]
|
||
players: Player[]
|
||
holesBySide: Record<string, ApiSideHole[]>
|
||
holeOrder: number[]
|
||
holesPlanned: number
|
||
currentHole: number
|
||
readOnly: boolean
|
||
// Match-aware fargelegging + Stilling-rad (2026-07-29) -- delt-ball-
|
||
// formater (foursome/greensome/scramble) er ALLE to-sidede per ADR-039,
|
||
// så denne er reelt alltid sann her, men holdt eksplisitt for symmetri
|
||
// med ScorecardGrid.
|
||
isTwoSided: boolean
|
||
formatResult: ApiFormatResult | null
|
||
onSelectHole: (holeNumber: number) => void
|
||
onOpenEntry: (sideId: string, holeNumber: number) => void
|
||
}) {
|
||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||
for (const list of Object.values(holesBySide)) {
|
||
for (const h of list) {
|
||
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
|
||
}
|
||
}
|
||
|
||
const showSplit = holesPlanned === 18
|
||
const front = holeOrder.filter((n) => n <= 9)
|
||
const back = holeOrder.filter((n) => n >= 10)
|
||
|
||
function sumFor(sideId: string, numbers: number[]): { strokes: number; par: number } | null {
|
||
const list = holesBySide[sideId]
|
||
if (!list || numbers.length === 0) return null
|
||
const played = numbers
|
||
.map((n) => list.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiSideHole => Boolean(h) && h!.played && h!.score !== null)
|
||
if (played.length === 0) return null
|
||
return {
|
||
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
|
||
par: played.reduce((sum, h) => sum + h.par, 0),
|
||
}
|
||
}
|
||
|
||
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
|
||
|
||
// entries[].id == round_side.id for delt-ball-formater (samme
|
||
// _build_format_result-kontrakt som ScorecardGrid bruker for spillere).
|
||
const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
|
||
const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
|
||
const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map()
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||
<table className="w-full border-separate border-spacing-0 text-sm">
|
||
<colgroup>
|
||
<col className="w-[130px]" />
|
||
{holeOrder.map((n) => (
|
||
<col key={n} className="w-[52px]" />
|
||
))}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
<col className="w-[60px]" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border">
|
||
<th
|
||
scope="col"
|
||
className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
|
||
>
|
||
Side
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const active = n === currentHole
|
||
return (
|
||
<th key={n} scope="col" className="border-l border-border p-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectHole(n)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Velg hull ${n}`}
|
||
className={cn(
|
||
"flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
||
active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
</th>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Ut
|
||
</th>
|
||
)}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Inn
|
||
</th>
|
||
)}
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Sum
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr className="border-b border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Hcp
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.index ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
<tr className="border-b-2 border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Par
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.par ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{parRowTotal || "–"}
|
||
</td>
|
||
</tr>
|
||
|
||
{sides.map((side, sideIndex) => {
|
||
const list = holesBySide[side.id]
|
||
const frontSum = showSplit ? sumFor(side.id, front) : null
|
||
const backSum = showSplit ? sumFor(side.id, back) : null
|
||
const totalSum = sumFor(side.id, holeOrder)
|
||
const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name)
|
||
const sideTone: "a" | "b" = sideIndex === 0 ? "a" : "b"
|
||
return (
|
||
<tr key={side.id} className="border-b border-border last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(side.id, currentHole)}
|
||
aria-label={`Registrer score for ${side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} på hull ${currentHole}`}
|
||
className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
{showMatchColors && (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn("size-2 shrink-0 rounded-full", sideTone === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
/>
|
||
)}
|
||
<span className="truncate text-sm font-bold text-foreground">
|
||
{side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`}
|
||
</span>
|
||
</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"}
|
||
</span>
|
||
</button>
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const apiHole = list?.find((h) => h.hole_number === n) ?? null
|
||
const meta = holeMeta[n]
|
||
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
|
||
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
|
||
const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined
|
||
const entry = formatHole?.entries.find((e) => e.id === side.id)
|
||
const won = Boolean(entry && formatHole?.result === sideTone)
|
||
return (
|
||
<td key={n} className="border-l border-border p-0 text-center">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(side.id, n)}
|
||
aria-label={
|
||
hasScore
|
||
? `${side.label ?? "Side"}, hull ${n}, ${apiHole?.score} slag${won ? ", vant hullet" : ""}`
|
||
: `Registrer score for hull ${n}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
|
||
}
|
||
className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
|
||
>
|
||
{showMatchColors ? (
|
||
<MatchScorecardCell
|
||
value={hasScore ? (apiHole!.score as number) : null}
|
||
side={sideTone}
|
||
won={won}
|
||
dimmed={false}
|
||
/>
|
||
) : (
|
||
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
|
||
)}
|
||
{/* Mottatte slag vist FØR hullet er fylt ut (2026-07-29) --
|
||
samme kombinerte side-verdi som ville blitt trukket fra
|
||
ved utfylling. Prikker i stedet for "−N"-tekst (2026-07-30). */}
|
||
{!hasScore && apiHole?.strokes_received != null && apiHole.strokes_received > 0 && (
|
||
<StrokeDots count={apiHole.strokes_received} />
|
||
)}
|
||
</button>
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{frontSum ? frontSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{backSum ? backSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center">
|
||
{totalSum ? (
|
||
<span className="flex flex-col items-center leading-tight">
|
||
<span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
|
||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
|
||
{formatToPar(totalSum.strokes - totalSum.par)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
|
||
fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
|
||
lead-poeng per vunnet hull som resten av to-sidede formater
|
||
(denne raden ville da vist en misvisende match-play-tekst
|
||
basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
|
||
vises i stedet i FormatResultPanel over selve gridet --
|
||
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
|
||
vises uendret. Formatet er uansett aldri delt-ball, så denne
|
||
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
|
||
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
|
||
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||
Stilling
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const s = stillingByHole.get(n)
|
||
return (
|
||
<td
|
||
key={n}
|
||
className={cn(
|
||
"border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
|
||
!s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
|
||
)}
|
||
>
|
||
{s?.label ?? "–"}
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Delt-ball-scoreveiviser (ADR-039 Beslutning C) --------------------------
|
||
// Enklere enn ScoringWizard -- kun ett tall (slagtall for hele siden), ingen
|
||
// putt-/detalj-steg. Samme "Neste side" → "Neste hull"-samlebåndsprinsipp.
|
||
|
||
function SideScoreWizard({
|
||
sides,
|
||
side,
|
||
sideParticipants,
|
||
hole,
|
||
score,
|
||
selectedParticipantId,
|
||
onSave,
|
||
onSelectParticipant,
|
||
onClose,
|
||
onAdvance,
|
||
readOnly,
|
||
isPending,
|
||
}: {
|
||
sides: ApiSide[]
|
||
side: ApiSide
|
||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
|
||
sideParticipants: Player[]
|
||
hole: Hole
|
||
score: number | null
|
||
selectedParticipantId: string | null
|
||
onSave: (score: number | null) => void
|
||
onSelectParticipant: (participantId: string | null) => void
|
||
onClose: () => void
|
||
onAdvance: () => void
|
||
readOnly: boolean
|
||
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- se
|
||
// ScoringWizard sin samme prop for full begrunnelse.
|
||
isPending: boolean
|
||
}) {
|
||
const sideIndex = sides.findIndex((s) => s.id === side.id)
|
||
const isLast = sideIndex === -1 || sideIndex === sides.length - 1
|
||
const nextSide = !isLast ? sides[sideIndex + 1] : null
|
||
|
||
function sideLabel(s: ApiSide, idx: number) {
|
||
return s.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
}
|
||
|
||
// Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
|
||
// slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
|
||
// når det ikke finnes noe utslags-valg å ta stilling til også (migrasjon
|
||
// 036) -- ellers ville auto-hoppet revet brukeren videre FØR de rakk å
|
||
// velge hvem sitt utslag som ble brukt (det valgfrie feltet under).
|
||
const autoAdvance = sideParticipants.length === 0
|
||
const enteredWithValueRef = useRef(score !== null)
|
||
const firedRef = useRef(false)
|
||
useEffect(() => {
|
||
firedRef.current = false
|
||
enteredWithValueRef.current = score !== null
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [side.id, hole.holeNumber])
|
||
useEffect(() => {
|
||
if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
|
||
if (score !== null) {
|
||
firedRef.current = true
|
||
onAdvance()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [score, autoAdvance])
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Lukk veiviseren"
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
<X aria-hidden="true" className="size-5" />
|
||
</button>
|
||
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||
<span className="truncate text-base font-extrabold text-foreground">
|
||
Hull {hole.holeNumber} · Par {hole.par}
|
||
</span>
|
||
<span className="truncate text-sm font-semibold text-muted-foreground">{sideLabel(side, sideIndex)}</span>
|
||
</div>
|
||
<div className="size-11 shrink-0" aria-hidden="true" />
|
||
</header>
|
||
|
||
<div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||
{sides.map((s, i) => (
|
||
<span
|
||
key={s.id}
|
||
className={cn(
|
||
"flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
|
||
s.id === side.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground",
|
||
)}
|
||
>
|
||
{sideLabel(s, i)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{isPending && (
|
||
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||
Lagret lokalt · venter på synk
|
||
</p>
|
||
)}
|
||
|
||
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||
<NumberPicker
|
||
label="Slag"
|
||
value={score}
|
||
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
|
||
expandLabel="10+"
|
||
parValue={hole.par}
|
||
showGolfTerms
|
||
onChange={(v) => onSave(v)}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
{/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036,
|
||
FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per
|
||
spiller"). Helt valgfritt -- egen handling, rører aldri
|
||
slagtallet. */}
|
||
{sideParticipants.length > 0 && (
|
||
<fieldset className="flex flex-col gap-2">
|
||
<legend className="mb-1 text-sm font-bold text-foreground">
|
||
Hvem sitt utslag ble brukt? <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</legend>
|
||
<div className="flex flex-wrap gap-2">
|
||
{sideParticipants.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onSelectParticipant(selectedParticipantId === p.id ? null : p.id)}
|
||
aria-pressed={selectedParticipantId === p.id}
|
||
className={cn(
|
||
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60",
|
||
selectedParticipantId === p.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
|
||
<Button type="button" variant="outline" onClick={onClose} className="h-14 rounded-2xl text-base font-bold">
|
||
Avbryt
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={score === null}
|
||
onClick={onAdvance}
|
||
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
|
||
>
|
||
{isLast ? "Ferdig" : `Neste: ${sideLabel(nextSide!, sideIndex + 1)}`}
|
||
</Button>
|
||
</footer>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Wizard-seksjon (2026-08-11) -------------------------------------------
|
||
// Liten seksjonsoverskrift + hårfin divider, brukt til å dele detalj-steget
|
||
// i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon
|
||
// -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens).
|
||
// --- "Mer innhold under"-hint (2026-08-11) ---------------------------------
|
||
// Legger en myk bunn-fade + nedoverpil over et scrollbart område SÅ LENGE det
|
||
// finnes mer innhold under fold-linjen -- og skjuler seg av seg selv idet man
|
||
// har scrollet helt ned. Fanger både resize og innholdsendringer (ulikt
|
||
// stat_level/par gir ulik høyde) via en ResizeObserver på selve scrollboksen.
|
||
// Høyden (px) på fade-/piloverlayet under -- MÅ matche spacer-diven sin
|
||
// h-16 og trekkes fra i hasMore-utregningen, se begrunnelse der.
|
||
const SCROLL_FADE_HEIGHT_PX = 64
|
||
|
||
function ScrollFade({ children, className }: { children: React.ReactNode; className?: string }) {
|
||
const scrollRef = useRef<HTMLDivElement>(null)
|
||
const contentRef = useRef<HTMLDivElement>(null)
|
||
const [hasMore, setHasMore] = useState(false)
|
||
useEffect(() => {
|
||
const scrollEl = scrollRef.current
|
||
const contentEl = contentRef.current
|
||
if (!scrollEl || !contentEl) return
|
||
const update = () => {
|
||
// Trekker fra spacer-divens egen høyde (samme SCROLL_FADE_HEIGHT_PX)
|
||
// i tillegg til 4px slingring -- uten dette ville "hasMore" blitt
|
||
// sant utelukkende fordi spaceren selv gjør scrollHeight større, selv
|
||
// når ALT ekte innhold allerede er synlig (falskt positivt hint).
|
||
setHasMore(scrollEl.scrollTop + scrollEl.clientHeight < scrollEl.scrollHeight - SCROLL_FADE_HEIGHT_PX - 4)
|
||
}
|
||
update()
|
||
scrollEl.addEventListener("scroll", update, { passive: true })
|
||
// Observerer INNHOLDS-diven, IKKE scroll-beholderen selv --
|
||
// beholderens egen boks-størrelse er fast (flex-1, bundet av
|
||
// veiviserens layout), den endrer seg ALDRI når man bytter steg. Uten
|
||
// dette ble "hasMore" aldri regnet på nytt ved stegbytte -- den
|
||
// sprettende pilen dukket rett og slett ikke opp på steg som faktisk
|
||
// trengte scrolling (f.eks. "Retning"-steget med kølle-rutenett).
|
||
const ro = new ResizeObserver(update)
|
||
ro.observe(contentEl)
|
||
return () => {
|
||
scrollEl.removeEventListener("scroll", update)
|
||
ro.disconnect()
|
||
}
|
||
}, [])
|
||
return (
|
||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||
<div ref={scrollRef} className={className}>
|
||
<div ref={contentRef}>{children}</div>
|
||
{/* Usynlig buffer, SAMME høyde som fade-/piloverlayet -- garanterer at
|
||
overlayet aldri dekker et ekte, interaktivt element (f.eks. en
|
||
retningsknapp med sin egen pil-ikon nederst i steget). Uten denne
|
||
kunne den sprettende pilen havne rett oppå en annen pil. */}
|
||
<div aria-hidden="true" className="h-16 shrink-0" />
|
||
</div>
|
||
{/* Rent dekorativt -- selve scrollingen er fullt mulig uten dette. */}
|
||
<div
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"pointer-events-none absolute inset-x-0 bottom-0 flex h-16 items-end justify-center bg-gradient-to-t from-background to-transparent pb-1.5 transition-opacity duration-200",
|
||
hasMore ? "opacity-100" : "opacity-0",
|
||
)}
|
||
>
|
||
<ChevronDown className="size-5 animate-bounce text-muted-foreground" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|