diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 389fa25..02fd08b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -257,7 +257,11 @@ "Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)", "Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/del_cookies.txt /tmp/captvis_cookies.txt)", - "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)" + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)", + "Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py /opt/teecup/app/routers/scoring.py)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/score_cookies.txt)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-7)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/sc_cookies.txt)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 95df62d..79e8007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -880,11 +880,53 @@ Ferdig og verifisert: **Rullet ut live**, bruker bekreftet eksplisitt: begge containere bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket. +- **Backend-forarbeid for scorekort-skjermen, LIVE (2026-07-18):** to hull + funnet ved gjennomlesing av `scoring.py` FØR V0-prompten ble skrevet, + samme føre-var-mønster som resten av økten. + 1. **Hull-/stroke-index-data for egendefinerte baner** (bevisst utsatt fra + tee-rundens status-notat) — `hole`-tabellen har ALDRI hatt noen vei inn + for custom-baner, som blokkerte `stroke`-scoringsmodus helt (`hole_ + result`-modus trenger den ikke). Ny `GET/POST /orgs/{id}/courses/{id}/ + holes` i `app/routers/courses.py` — engangs alle-18-på-en-gang + (avviser feil antall, avviser hvis banen allerede har hull, avviser på + offisielle baner som får hullene sine fra teeoff-import). + 2. **`GET scorecard` viste kun UTLEDET vinn/tap/delt per hull, aldri de + faktiske tallene som var registrert** — umulig å bygge en skjerm som + viser gjeldende tilstand ved gjenlasting. Utvidet `Scorecard`-modellen + i `app/routers/scoring.py` med `stroke_entries`/`hole_result_entries` + (rå `hole_score`/`match_hole_result`-rader, nøyaktig ett av de to fylt + ut avhengig av øktens scoring_mode). + **Verifisert grundig i scratch:** hull-validering (feil antall avvist, + gjentatt oppsett avvist), og en FULL `stroke`-modus scoringsrunde med + ekte handicap-justert nettoberegning på en egendefinert bane for FØRSTE + gang noensinne (ulikt slagtall — 4 mot 5 — ga korrekt "halved" etter + handicap-utjevning), scorecard sin nye `stroke_entries` bekreftet å + returnere nøyaktig det som ble registrert. `test_isolation.sql` 12/12. + **Rullet ut live**, ingen migrasjon, kun `teecup_api` redeployet, + `teeoff.no` upåvirket. + +- **Scorekort-skjermen LIVE (2026-07-18):** åttende V0-skjerm, + `components/session-scorecard.tsx`, ny rute `/tournaments/[id]/sessions/ + [sessionId]/matches/[matchId]`. Ett hull i fokus om gangen (banebruk, + ikke et regneark) — store slag-steppere for `stroke`-modus, tre-valgs + vinner-knapper for `hole_result`-modus, hull-chip-navigasjon, kollapsbar + full oversikt, feiret "avgjort"-banner som låser alt til read-only. + Blind draw sin avslørte visning lenker nå til hver match sitt scorekort. + **Ingen nye backend-hull denne runden** — forrige rundes forarbeid + (hull-endepunkter + `stroke_entries`/`hole_result_entries` i scorecard) + dekket akkurat det skjermen trengte. + **Verifisert grundig i scratch, inkludert et fullt oppsett som speiler + NØYAKTIG frontend-ens egen last-sekvens** (sessions→teams→matches→ + holes→scorecard, deretter en ekte `POST hole-scores` for begge spillere + på hull 1 og en refetch): status_text/derivert resultat oppdaterte seg + korrekt ("1 UP (A)", hull 1 → "a"), alle feltnavn stemte eksakt med + TypeScript-typene uten justering. `test_isolation.sql` 12/12, ekte + typesjekket build. + **Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket. + Neste steg: -1. Scorekort-skjermen (neste i "bygg i rekkefølgen ting brukes") — her må - også hull-/stroke-index-data for egendefinerte baner løses (bevisst - utsatt fra tee-rundens tidligere status-notat). Deretter leaderboard. - Samme mønster: +1. Leaderboard-skjermen (siste i "bygg i rekkefølgen ting brukes"-serien + for match-play-flyten). Samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index e92971f..9fd5d37 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -400,6 +400,18 @@ - **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert (`app/routers/scoring.py`: `hole-scores` for `stroke`-modus, `hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`). +- **Nytt 2026-07-18, ✅ BYGGET (forarbeid før scorekort-skjermen):** + `GET/POST /orgs/{id}/courses/{id}/holes` løser hull-/stroke-index-hullet + fra tee-runden (kun `stroke`-modus trengte det). `GET scorecard` utvidet + med `stroke_entries`/`hole_result_entries` (rå registrerte tall, ikke + bare utledet vinn/tap/delt) — uten dette kunne ikke en gjenlastet + scorekort-side vise gjeldende tilstand. Verifisert med en full + `stroke`-runde med ekte handicap-justering på en egendefinert bane. +- **Frontend LIVE 2026-07-18:** `/tournaments/[id]/sessions/[sessionId]/ + matches/[matchId]`, `components/session-scorecard.tsx`. Ett hull om + gangen med store steppere/knapper (banebruk i sollys, ikke et regneark + — matcher UX-visjonen lenger ned i denne fila). Lenket fra blind draw + sin avslørte visning. Ingen nye backend-hull denne runden. ### Bøtekasse (Kangaroo Court) - **Status:** 📋 planlagt diff --git a/app/routers/courses.py b/app/routers/courses.py index 4ac8bd0..3bf8979 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -167,6 +167,89 @@ async def create_tee( ) +# --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i -- +# scoring-fasen -- se app/routers/scoring.py sin bruk av `hole`-tabellen) - +# +# Egendefinerte baner har ALDRI hatt noen vei til hull-data, uavhengig av +# ADR-019 (samme klasse hull som tee-mangelen -- flagget FØR scorekort- +# skjermen ble bygget, ikke oppdaget for sent). Kun `stroke`-scoringsmodus +# trenger dette; `hole_result`-modus fungerer allerede uten. Engangs +# oppsett: enten alle 18 hull settes i ÉN innsending, eller ingen -- unngår +# kompleksiteten med delvise/partielle hull-sett. + + +class HoleCreate(BaseModel): + hole_number: int = Field(ge=1, le=18) + par: int = Field(ge=3, le=6) + stroke_index: int = Field(ge=1, le=18) + + +class Hole(BaseModel): + hole_number: int + par: int + stroke_index: int + + +@router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole]) +async def list_holes( + course_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[Hole]: + async with org_connection(organization_id) as conn: + rows = await conn.fetch( + "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", + course_id, + ) + return [Hole(**dict(r)) for r in rows] + + +@router.post("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole], status_code=201) +async def create_holes( + course_id: str, + body: list[HoleCreate], + organization_id: str = Depends(get_authorized_org), +) -> list[Hole]: + if len(body) != 18: + raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull i én innsending.") + numbers = sorted(h.hole_number for h in body) + if numbers != list(range(1, 19)): + raise app_error(400, "VALIDATION_FAILED", "Hullnumrene må dekke nøyaktig 1-18, hver én gang.") + + async with org_connection(organization_id) as conn, translate_db_errors(): + source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id) + if source is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") + if source == "official": + raise app_error( + 400, + "VALIDATION_FAILED", + "Offisielle baner får hullene sine fra teeoff -- kan ikke legges til manuelt.", + ) + existing = await conn.fetchval("SELECT count(*) FROM hole WHERE course_id = $1", course_id) + if existing > 0: + raise app_error( + 409, "DUPLICATE", "Banen har allerede registrert hull -- kan ikke settes opp på nytt." + ) + + rows = [] + for h in body: + rows.append( + await conn.fetchrow( + """ + INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) + VALUES ($1, $2, $3, $4, $5) + RETURNING hole_number, par, stroke_index + """, + organization_id, + course_id, + h.hole_number, + h.par, + h.stroke_index, + ) + ) + return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number) + + # --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 8037501..a689a27 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -401,6 +401,12 @@ class Scorecard(BaseModel): points_side_a: float | None points_side_b: float | None holes: list[ScorecardHole] + # Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som + # faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet + # (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de + # to feltene er fylt ut, avhengig av øktens scoring_mode. + stroke_entries: list[HoleScoreOut] | None = None + hole_result_entries: list[HoleResultOut] | None = None _RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"} @@ -430,11 +436,35 @@ async def get_scorecard( raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") results = await _compute_hole_results(conn, match_id, match) + stroke_entries = None + hole_result_entries = None + if match["scoring_mode"] == "stroke": + rows = await conn.fetch( + """ + SELECT hole_number, team_side::text AS team_side, + match_participant_id::text AS match_participant_id, gross_strokes + FROM hole_score WHERE match_id = $1 ORDER BY hole_number + """, + match_id, + ) + stroke_entries = [HoleScoreOut(**dict(r)) for r in rows] + else: + rows = await conn.fetch( + """ + SELECT hole_number, winning_side::text AS winning_side + FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number + """, + match_id, + ) + hole_result_entries = [HoleResultOut(**dict(r)) for r in rows] + played = _played_hole_numbers(match["hole_config"]) holes = [ ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results) ] return Scorecard( + stroke_entries=stroke_entries, + hole_result_entries=hole_result_entries, match_id=match["id"], status_text=match["status_text"], points_side_a=match["points_side_a"], diff --git a/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx b/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx new file mode 100644 index 0000000..9a42771 --- /dev/null +++ b/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx @@ -0,0 +1,35 @@ +import { SessionScorecard } from "@/components/session-scorecard" + +export default async function SessionScorecardPage({ + params, + searchParams, +}: { + params: Promise<{ id: string; sessionId: string; matchId: string }> + searchParams: Promise<{ org?: string; name?: string }> +}) { + const { id, sessionId, matchId } = await params + const { org, name } = await searchParams + const tournamentName = name?.trim() || "Turnering" + + // Samme grunn som de andre turnering-undersidene: API-et krever + // organization_id på alle match-/scoring-endepunkter (RLS, se ADR-001/003). + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/session-blind-draw.tsx b/frontend/components/session-blind-draw.tsx index 1df53cd..d222d82 100644 --- a/frontend/components/session-blind-draw.tsx +++ b/frontend/components/session-blind-draw.tsx @@ -343,7 +343,13 @@ export function SessionBlindDraw({ {bothLocked ? ( - + + `/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + } + /> ) : (
{teams.map((team, teamIndex) => ( @@ -682,13 +688,22 @@ function AddSlotForm({ // --- Revealed view --------------------------------------------------------- -function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches: ApiMatch[] }) { +function RevealedView({ + teams, + matches, + scorecardHrefFor, +}: { + teams: [ApiTeam, ApiTeam] + matches: ApiMatch[] + scorecardHrefFor: (matchId: string) => string +}) { return (
{matches.map((match) => ( -
@@ -709,7 +724,7 @@ function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches:
p.team_side === "b")} align="right" />
-
+ ))}
) diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx new file mode 100644 index 0000000..7caf06d --- /dev/null +++ b/frontend/components/session-scorecard.tsx @@ -0,0 +1,736 @@ +"use client" + +import type React from "react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + ArrowLeft, + Check, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + Minus, + Plus, + Trophy, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py) + +type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" +type ScoringMode = "stroke" | "hole_result" +type HoleConfig = "full_18" | "front_9" | "back_9" + +const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"] + +const FORMAT_LABELS: Record = { + foursome: "Foursome", + greensome: "Greensome", + scramble_2: "Scramble (2)", + scramble_4: "Scramble (4)", + fourball: "Fourball", + singles: "Singel", +} + +type ApiTeam = { id: string; name: string; color: string | null } + +type ApiSession = { + id: string + name: string | null + format: Format + hole_config: HoleConfig + course_id: string + scoring_mode: ScoringMode +} + +type ApiParticipant = { + id: string + team_side: "a" | "b" + team_roster_id: string + player_name: string + tee_id: string + tee_name: string +} + +type ApiMatch = { + id: string + sequence: number + team_a_id: string + team_b_id: string + participants: ApiParticipant[] +} + +type ApiHole = { hole_number: number; par: number; stroke_index: number } + +type ApiHoleScore = { + hole_number: number + team_side: "a" | "b" + match_participant_id: string | null + gross_strokes: number +} + +type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null } + +type ApiScorecard = { + match_id: string + status_text: string | null + points_side_a: number | null + points_side_b: number | null + holes: { hole_number: number; result: "a" | "b" | "halved" }[] + stroke_entries: ApiHoleScore[] | null + hole_result_entries: ApiHoleResult[] | null +} + +// Enhet som scores: én per SPILLER for individuelle formater (singles/ +// fourball), én per SIDE for delt-ball-formater (foursome/greensome/ +// scramble) -- se app/routers/scoring.py sin is_individual-sjekk. +type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null } + +function playedHoleNumbers(holeConfig: HoleConfig): number[] { + if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) + if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) + return Array.from({ length: 18 }, (_, i) => i + 1) +} + +export function SessionScorecard({ + organizationId, + tournamentId, + sessionId, + matchId, + tournamentName, +}: { + organizationId: string + tournamentId: string + sessionId: string + matchId: string + tournamentName: string +}) { + const [session, setSession] = useState(null) + const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) + const [match, setMatch] = useState(null) + const [holes, setHoles] = useState([]) + const [scorecard, setScorecard] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [currentHole, setCurrentHole] = useState(null) + const [showSummary, setShowSummary] = useState(false) + + async function refetchScorecard() { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { + credentials: "include", + }) + if (res.ok) setScorecard(await res.json()) + } + + useEffect(() => { + let cancelled = false + async function load() { + try { + const [sessionsRes, teamsRes, matchesRes] = await Promise.all([ + fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }), + ]) + if (!sessionsRes.ok || !teamsRes.ok || !matchesRes.ok) throw new Error("load failed") + const sessionsData: ApiSession[] = await sessionsRes.json() + const foundSession = sessionsData.find((s) => s.id === sessionId) + if (!foundSession) throw new Error("session not found") + const teamsData: ApiTeam[] = await teamsRes.json() + if (teamsData.length !== 2) throw new Error("expected 2 teams") + const matchesData: ApiMatch[] = await matchesRes.json() + const foundMatch = matchesData.find((m) => m.id === matchId) + if (!foundMatch) throw new Error("match not found") + + const [holesRes, scorecardRes] = await Promise.all([ + fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/holes`, { credentials: "include" }), + fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include" }), + ]) + if (!holesRes.ok || !scorecardRes.ok) throw new Error("load failed") + const holesData: ApiHole[] = await holesRes.json() + const scorecardData: ApiScorecard = await scorecardRes.json() + + if (cancelled) return + setSession(foundSession) + setTeams([teamsData[0], teamsData[1]]) + setMatch(foundMatch) + setHoles(holesData) + setScorecard(scorecardData) + const played = playedHoleNumbers(foundSession.hole_config) + const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1] + setCurrentHole(nextUnregistered) + } catch { + if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.") + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { + cancelled = true + } + }, [organizationId, tournamentId, sessionId, matchId]) + + const units: Unit[] = useMemo(() => { + if (!session || !match || !teams) return [] + const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) + if (isIndividual) { + return match.participants.map((p) => ({ + id: p.id, + side: p.team_side, + label: p.player_name, + matchParticipantId: p.id, + })) + } + return [ + { id: "a", side: "a", label: teams[0].name, matchParticipantId: null }, + { id: "b", side: "b", label: teams[1].name, matchParticipantId: null }, + ] + }, [session, match, teams]) + + const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null + + async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + team_side: unit.side, + match_participant_id: unit.matchParticipantId, + hole_number: holeNumber, + gross_strokes: grossStrokes, + }), + }) + if (!res.ok) throw new Error(`submit stroke: ${res.status}`) + await refetchScorecard() + } catch { + setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.") + } + } + + async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }), + }) + if (!res.ok) throw new Error(`submit result: ${res.status}`) + await refetchScorecard() + } catch { + setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.") + } + } + + const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + + if (loading || !session || !teams || !match || !scorecard || currentHole === null) { + return ( +
+
+
+ +
+
+
+ {error ? ( +

+ {error} +

+ ) : ( +
+
+ ) + } + + const played = playedHoleNumbers(session.hole_config) + const totalHoles = played.length + const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4 + const registeredHoleNumbers = new Set(scorecard.holes.map((h) => h.hole_number)) + + const statusLabel = scorecard.status_text ?? "AS" + + function strokeValue(holeNumber: number, unit: Unit): number | null { + const entry = (scorecard!.stroke_entries ?? []).find( + (e) => + e.hole_number === holeNumber && + e.team_side === unit.side && + e.match_participant_id === unit.matchParticipantId, + ) + return entry ? entry.gross_strokes : null + } + + function stepStroke(unit: Unit, delta: number) { + const current = strokeValue(currentHole!, unit) + const base = current === null ? par : current + const next = Math.max(1, Math.min(20, base + delta)) + void submitStroke(currentHole!, unit, next) + } + + const currentResultEntry = (scorecard.hole_result_entries ?? []).find( + (e) => e.hole_number === currentHole, + ) + + function goPrev() { + setCurrentHole((h) => { + const idx = played.indexOf(h!) + return played[Math.max(0, idx - 1)] + }) + } + function goNext() { + setCurrentHole((h) => { + const idx = played.indexOf(h!) + return played[Math.min(played.length - 1, idx + 1)] + }) + } + + const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) + const isLast = played.indexOf(currentHole) >= played.length - 1 + + return ( +
+
+
+ +
+ +
+
+ + {decided ? ( +
+ Avgjort +
+ ) : ( +
+ +
+ + {statusLabel} + +
+ +
+ )} + +
+
+
+ +
+ {error && ( +

+ {error} +

+ )} + + {decided ? ( + + ) : ( + <> + + +
+ + {showSummary && ( +
+ +
+ )} +
+ + {session.scoring_mode === "stroke" ? ( +
+
+

Hull {currentHole}

+ · Par {par} +
+
+ {units.map((unit) => { + const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" + const value = strokeValue(currentHole, unit) + return ( +
+
+
+
+ +
+ + {value === null ? "–" : value} + + + {value === null ? "Ikke registrert" : scoreToParLabel(value, par)} + +
+ +
+
+ ) + })} +
+
+ ) : ( +
+
+

Hull {currentHole}

+ · Par {par} +
+
+ {( + [ + { key: "a", label: `${teams[0].name} vant`, color: teams[0].color, side: "a" as const }, + { key: "h", label: "Delt", color: undefined, side: null }, + { key: "b", label: `${teams[1].name} vant`, color: teams[1].color, side: "b" as const }, + ] as const + ).map((opt) => { + const active = currentResultEntry + ? currentResultEntry.winning_side === opt.side + : false + return ( + + ) + })} +
+
+ )} + +
+ {!isLast ? ( + + ) : ( + + )} +
+ + )} +
+
+ ) +} + +// --- Small components -------------------------------------------------------- + +function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) { + const right = align === "right" + return ( +
+
+ ) +} + +function HoleSummaryTable({ + teams, + scorecard, + holes, + scoringMode, +}: { + teams: [ApiTeam, ApiTeam] + scorecard: ApiScorecard + holes: ApiHole[] + scoringMode: ScoringMode +}) { + if (scorecard.holes.length === 0) { + return

Ingen hull er registrert ennå.

+ } + + return ( +
    + {scorecard.holes.map((h) => { + const par = holes.find((x) => x.hole_number === h.hole_number)?.par + return ( +
  • +
    + + {h.hole_number} + + {par !== undefined && ( + Par {par} + )} + {scoringMode === "stroke" && ( + + {grossPairLabel(scorecard, h.hole_number)} + + )} +
    + +
  • + ) + })} +
+ ) +} + +function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string { + const entries = scorecard.stroke_entries ?? [] + const sideGross = (side: "a" | "b"): string => { + const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes) + if (vals.length === 0) return "–" + return String(Math.min(...vals)) + } + return `${sideGross("a")}–${sideGross("b")}` +} + +function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) { + if (result === "halved") { + return ( + + Delt + + ) + } + const team = result === "a" ? teams[0] : teams[1] + return ( + + {team.name} + + ) +} + +function scoreToParLabel(value: number, par: number): string { + const diff = value - par + if (diff === 0) return "Par" + if (diff === -1) return "Birdie" + if (diff === -2) return "Eagle" + if (diff <= -3) return "Albatross" + if (diff === 1) return "Bogey" + if (diff === 2) return "Dobbel bogey" + return `+${diff}` +} + +function DecidedView({ + teams, + scorecard, + holes, + units, + scoringMode, +}: { + teams: [ApiTeam, ApiTeam] + scorecard: ApiScorecard + holes: ApiHole[] + units: Unit[] + scoringMode: ScoringMode +}) { + return ( +
+
+
+
+
+ Matchen er avgjort + {scorecard.status_text ?? ""} +
+
+ +
+
+
+
+ + {formatPoints(scorecard.points_side_a ?? 0)} + +
+
Poeng
+
+
+
+ + {formatPoints(scorecard.points_side_b ?? 0)} + +
+
+ +
+

Hull for hull

+
+ +
+

+ Resultatet kan ikke endres etter at matchen er avgjort. +

+
+
+ ) +} + +function formatPoints(points: number) { + return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") +}