Fullt flerspiller-scorekort-grid + offline-kø for score-føring (ADR-104)

Punkt 4 og 1 i scoring-paritet-gap-listen, bygget samtidig (samme fil,
begge små/uavhengige endringer). Ny TournamentScorecardGrid erstatter
pille-rad + enkelt-spiller-HoleGrid (nå slettet) med et ekte
alle-spillere×alle-hull-rutenett, samme struktur som round-detail.tsx
sin ScorecardGrid men denne filens egne classify/scoreMarkClasses.
updateHoleFull fikk samme offline-kø-/409-håndtering som round-detail.tsx
sitt etablerte mønster (ADR-028/057/060), samme delte offline-queue.ts
uendret. Ingen backend-endring.

Ikke rullet ut mot ekte containere ennå -- venter på brukerbekreftelse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-22 07:49:05 +02:00
parent 8c3cdfff2e
commit 629197d22a
4 changed files with 559 additions and 185 deletions

View file

@ -10432,6 +10432,98 @@ Mapbox-token-miljøvariabel underveis) ryddet opp etter verifisering.
--- ---
## ADR-104: Fullt flerspiller-scorekort-grid + offline-kø for score-føring i ScoreTab (2026-08-22)
**Kontekst:** punkt 4 og 1 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bedt om samtidig av
bruker siden punkt 1 er en liten oppgave og begge berører samme fil
(`ScoreTab` i `individual-tournament-detail.tsx`). Ingen backend-
endring i noen av delene -- ren frontend-runde.
**Punkt 4 -- flerspiller-scorekort-grid.**
**Beslutning A -- gjenbruk denne filens EGNE `classify`/
`scoreMarkClasses`, ikke `round-detail.tsx` sin `ScorecardCell`.** Den
nye `TournamentScorecardGrid`-komponenten speiler `ScorecardGrid`
strukturelt (sticky spillerkolonne, hull-kolonner gruppert Ut(1-9)/
Inn(10-18) på fysisk hullnummer, Sum-kolonne), men bruker
`individual-tournament-detail.tsx` sitt eget score-tone-språk (allerede
brukt av flight-kortlisten) for visuell konsistens innad i filen,
fremfor å importere `round-detail.tsx` sin lokale celle-komponent.
**Beslutning B -- droppet ved porting, bekreftet trygt.** Ingen
`sides`/`isTwoSided`/`formatResult`/match-fargelegging/"Stilling"-rad --
individuelle turneringer har ALDRI et side-begrep (denne grenen i
`ScorecardGrid` er alltid death-path for ren slagspill uansett, bekreftet
ved kodesøk). Ingen "plukket opp"/PU-merkelapp -- `tournament_round_hole`
har ingen tilsvarende kolonne.
**Beslutning C -- gridet ERSTATTER pille-rad + enkelt-spiller-`HoleGrid`,
ikke lagt til ved siden av.** `HoleGrid`-komponenten (kun brukt fra dette
ene stedet) ble slettet -- ingen gjenværende kallesteder. Cellene tapper
DIREKTE inn i den eksisterende `TournamentScoringWizard`-mounten (samme
instans som hull-navigatoren/flight-kortlisten over bruker) ved å sette
`activeHole` + `wizardPlayerId` -- ingen ny wizard-instans, ingen separat
`sheetHole`-state slik det gamle `HoleGrid` hadde.
**Beslutning D -- datahenting utvidet, men betinget.** `holesByParticipant`-
hente-effekten hentet tidligere kun flighten + valgt spiller. Gridet
trenger ALLE `visibleParticipants` sine hull (avhenger av "Vis hele
feltet"-bryteren) -- effekten utvidet til å dekke hele det synlige feltet,
men KUN når `showFullScorecard`-seksjonen faktisk er åpen (unngår
unødvendig henting når den er kollapset). `showFullScorecard`-state
flyttet tidligere i komponenten (var deklarert etter det opprinnelige
bruksstedet) slik at hente-effekten kan lese den.
**Punkt 1 -- offline-kø for score-føring.**
**Beslutning E -- speiler `round-detail.tsx` sitt
`queueParticipantHoleWrite`/`updateWizardStat`-mønster (ADR-028/057/060)
nøyaktig, samme delte `frontend/lib/offline-queue.ts` uendret.**
`updateHoleFull` fikk samme tre-veis gren: `!navigator.onLine` → kø
umiddelbart; `fetch` kaster (ekte nettverksfeil) → samme kø-fallback;
`res.status === 409` → konfliktvarsel + reload for den deltakeren (manglet
HELT tidligere -- `!res.ok` kastet uansett statuskode). Andre `!res.ok`
kaster fortsatt en generisk feil (uendret fra før -- kun 409 og ekte
nettverksfeil fikk ny håndtering, ingen regresjon på ekte
valideringsfeil). `patch.expected_version` er allerede satt av
`TournamentScoringWizard` sin `save()` (`expected_version: hole.version`)
-- ingen eget versjonsoppslag trengs i kø-hjelperen, ulikt
round-detail.tsx sin variant (som har flere kallesteder uten den
garantien).
**Beslutning F -- samme tre synk-utløsere, samme banner-språk.**
`window` sitt `"online"`-event, én engangssjekk ved mount
(`queueCount(roundId)`), og en manuell "Synkroniser nå"-knapp -- pluss
identisk offline-/venter-banner-markup (amber, `WifiOff`-ikon) plassert
øverst i `ScoreTab`s JSX (synlig uansett hvilken underseksjon som er
åpen). `TournamentScoringWizard.save()` trengte INGEN endring -- den
kaster kun videre hvis `onSave` selv kaster, og en kølagt skriving kaster
aldri (resolver normalt), så arket lukkes/avanserer allerede riktig ved
en offline-registrering.
**Status:** `tsc --noEmit` rent, 188/188 pytest uendret (ingen
backend-endring i det hele tatt). Full-stack scratch (egen db-/API-/
MinIO-container, ekte innlogging, ingen fetch-mocking) med en flight på 3
spillere (én med delvis utfylt scorekort fra før) bekreftet: gridet viser
alle tre samtidig med riktig Ut/Inn/Sum/golfscore-form, tapp på en
annen spillers celle enn den aktive åpner riktig wizard for akkurat den
spilleren+hullet (kjeden/"Neste"-navigasjonen uendret), et reelt 403
(selv-only-avvisning, forsøkte lagre for en annen deltaker som vanlig
medlem) bekreftet UENDRET riktig oppførsel fra før. Offline-kø: frakoblet
→ registrerte score → banner + optimistisk grid-oppdatering uten
feilmelding → tilkoblet → automatisk synk (banner forsvant) → bekreftet
direkte mot databasen at BÅDE det online og det kølagte hullet faktisk
ble lagret riktig server-side. Mørk modus + smal mobilskjerm. 409-
konflikthåndteringen ble IKKE eksplisitt live-reprodusert denne runden
(vurdert lavere presserende å simulere enn de to nye atferdene over,
siden selve mekanismen -- `expected_version`/409-semantikk -- allerede
er bevist riktig på nøyaktig dette endepunktet fra tidligere runder,
og round-detail.tsx sitt identiske mønster er langvarig i produksjon).
Alle scratch-ressurser ryddet opp.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14514,3 +14514,42 @@ Neste steg:
kjøring). `docker compose build teecup_api teecup_frontend && up kjøring). `docker compose build teecup_api teecup_frontend && up
-d`, begge containere friske, rene logger. -d`, begge containere friske, rene logger.
`https://teecup.golf/health` 200 etter utrulling. `https://teecup.golf/health` 200 etter utrulling.
162. **Fullt flerspiller-scorekort-grid + offline-kø for score-føring i
ScoreTab (ADR-104), 2026-08-22 -- punkt 4 og 1 i gap-listen, bedt om
samtidig siden punkt 1 er en liten oppgave.** Ren frontend-runde,
ingen backend-endring.
**Punkt 4:** "Vis hele scorekortet" viste tidligere kun en navne-
pille-rad + ett enkelt-spiller-`HoleGrid` (bytt HVEM som vises). Ny
`TournamentScorecardGrid` speiler `round-detail.tsx` sin
`ScorecardGrid` strukturelt (sticky spillerkolonne, Ut/Inn/Sum), men
bruker denne filens EGNE `classify`/`scoreMarkClasses` for visuell
konsistens med flight-kortlisten. `HoleGrid` (kun ett kallsted)
slettet -- cellene tapper direkte inn i den eksisterende
`TournamentScoringWizard`-mounten (setter `activeHole` +
`wizardPlayerId`), ingen ny wizard-instans. Hente-effekten for
`holesByParticipant` utvidet til å dekke hele det synlige feltet,
men kun når seksjonen faktisk er åpen.
**Punkt 1:** `updateHoleFull` hadde ingen offline-kø-kobling --
speiler nå `round-detail.tsx` sitt `queueParticipantHoleWrite`/
`updateWizardStat`-mønster (ADR-028/057/060) nøyaktig, samme delte
`frontend/lib/offline-queue.ts` uendret. Samme banner-språk, samme
tre synk-utløsere (online-event, mount-sjekk, manuell knapp). 409-
håndtering lagt til for første gang i denne filen (manglet helt før).
**Verifisert:** `tsc --noEmit` rent, 188/188 pytest uendret (ingen
backend rørt). Full-stack scratch (egen db/API-/MinIO-container,
ekte innlogging, ingen fetch-mocking) med en flight på 3 spillere:
gridet viser alle samtidig med riktig Ut/Inn/Sum/golfscore-form,
tapp på annen spiller enn aktiv åpner riktig wizard for nøyaktig den
spilleren+hullet, et reelt 403 (selv-only-avvisning) bekreftet
uendret riktig. Offline-kø: frakoblet → registrert → banner +
optimistisk oppdatering uten feil → tilkoblet → automatisk synk →
bekreftet direkte mot databasen at BÅDE det online og det kølagte
hullet ble lagret riktig. Mørk modus + smal mobilskjerm. Alle
scratch-ressurser ryddet opp.
**IKKE rullet ut ennå** -- venter på brukerbekreftelse (ingen
migrasjon -- kun `docker compose build teecup_frontend && up -d`).

View file

@ -4946,14 +4946,11 @@ flyter), kombinert frittstående+turnering hull-historikk (ADR-071).
**Gjenstående gap, rangert etter antatt størrelse/verdi (frittstående **Gjenstående gap, rangert etter antatt størrelse/verdi (frittstående
runde HAR, org-turnering MANGLER, om ikke annet er sagt):** runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
1. **Offline-kø for score-føring.** `round-detail.tsx` (og 1. ✅ **Offline-kø for score-føring -- FERDIG BYGGET 2026-08-22
`session-scorecard.tsx` for lag-kamper) bruker `frontend/lib/ (ADR-104), se CHANGELOG punkt 162.** `updateHoleFull` i `ScoreTab`
offline-queue.ts` (IndexedDB, ADR-028) ved tapt nett. `ScoreTab`/ speiler nå `round-detail.tsx` sitt kø-/synk-mønster nøyaktig, samme
`individual-tournament-detail.tsx` har INGEN referanse til denne delte `frontend/lib/offline-queue.ts` uendret. Bygget samtidig med
koen i det hele tatt -- mister score ved dårlig dekning på banen. punkt 4 (se under).
Antatt **liten** jobb: biblioteket er allerede generisk (bevist ved
at lag-kamper bruker det), ingen backend-endring, kun kabling rundt
PATCH-kallet i ScoreTab.
2. **Sanntidsoppdatering (live push) i den autentiserte scorings-/ 2. **Sanntidsoppdatering (live push) i den autentiserte scorings-/
leaderboard-visningen.** `round-detail.tsx` åpner en websocket leaderboard-visningen.** `round-detail.tsx` åpner en websocket
(`/ws/rounds/{roundId}/live`) og oppdaterer automatisk når andre i (`/ws/rounds/{roundId}/live`) og oppdaterer automatisk når andre i
@ -4970,14 +4967,12 @@ runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
og org-turneringer har ingen egen tilsvarende endepunkt/side i det og org-turneringer har ingen egen tilsvarende endepunkt/side i det
hele tatt. Antatt **middels/stor** -- ekte datamodell-brobygging hele tatt. Antatt **middels/stor** -- ekte datamodell-brobygging
(avstemme `stat_level`-forskjeller), ikke bare gjenbruk av UI. (avstemme `stat_level`-forskjeller), ikke bare gjenbruk av UI.
4. **"Vis hele scorekortet" i ScoreTab viser IKKE alle spillere 4. ✅ **Fullt flerspiller-scorekort-grid -- FERDIG BYGGET 2026-08-22
samtidig.** Frittstående runde sin `ScorecardGrid` er en ekte (ADR-104), se CHANGELOG punkt 162.** Ny `TournamentScorecardGrid`
alle-spillere×alle-hull-matrise. Org-turneringens likt navngitte (ekte alle-spillere×alle-hull-matrise, samme struktur som
toggle gjenbruker bevisst kun det eksisterende ett-spiller-`HoleGrid` round-detail.tsx sin ScorecardGrid) erstattet den gamle pille-rad +
(kommentar i koden bekrefter dette var en villet avgrensning den enkelt-spiller-`HoleGrid`-visningen (nå slettet). Bygget samtidig med
gang, ikke en forglemmelse) -- bytter kun HVEM som vises, viser aldri punkt 1 (se over).
alle på én gang. Antatt **middels** -- kjedet scoring (ADR-100) henter
allerede alle flightmedlemmers hull parallelt, så data finnes.
5. **"Plukket opp"-hurtigknapp finnes ikke for turneringer, selv der 5. **"Plukket opp"-hurtigknapp finnes ikke for turneringer, selv der
Stableford er scoringsmetoden.** `round_hole.picked_up` (migrasjon Stableford er scoringsmetoden.** `round_hole.picked_up` (migrasjon
038) + knapp finnes i frittstående runder. `tournament_round_hole` 038) + knapp finnes i frittstående runder. `tournament_round_hole`

View file

@ -35,12 +35,14 @@ import {
MapPin, MapPin,
Medal, Medal,
Plus, Plus,
RefreshCw,
Settings2, Settings2,
Trash2, Trash2,
Trophy, Trophy,
Upload, Upload,
UserPlus, UserPlus,
Users, Users,
WifiOff,
X, X,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -74,6 +76,7 @@ import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/c
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { CourseTemplatePicker } from "@/components/course-template-editor" import { CourseTemplatePicker } from "@/components/course-template-editor"
import { TournamentRoundMessages } from "@/components/tournament-round-messages" import { TournamentRoundMessages } from "@/components/tournament-round-messages"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
@ -3044,11 +3047,24 @@ function ScoreTab({
// "hent det som mangler"-mønster som round-detail.tsx sin // "hent det som mangler"-mønster som round-detail.tsx sin
// holesByParticipant/loadHoles. // holesByParticipant/loadHoles.
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({}) const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
// "Vis hele scorekortet" (ADR-099, steg 2; flerspiller-rutenett ADR-104
// punkt 4) -- kollapset som standard, samme fiks som round-detail.tsx sin
// PlayerHoleCards/ScorecardGrid-splitt. Deklarert her (ikke lenger sammen
// med wizardPlayerId lenger ned) siden hente-effekten rett under må vite
// om seksjonen faktisk er åpen før den henter HELE feltets hull.
const [showFullScorecard, setShowFullScorecard] = useState(false)
useEffect(() => { useEffect(() => {
if (!roundId) return if (!roundId) return
let cancelled = false let cancelled = false
// Flighten hentes alltid (hull-først-flyten trenger den uansett).
// Hele feltet hentes KUN når "Vis hele scorekortet" faktisk er åpen --
// ADR-104 punkt 4 sitt rutenett trenger alle visibleParticipants sine
// hull (avhenger av "Vis hele feltet"-bryteren), ikke bare flighten.
const ids = new Set(flightParticipants.map((rp) => rp.id)) const ids = new Set(flightParticipants.map((rp) => rp.id))
if (participantId) ids.add(participantId) if (participantId) ids.add(participantId)
if (showFullScorecard) {
for (const rp of visibleParticipants) ids.add(rp.id)
}
for (const id of ids) { for (const id of ids) {
if (holesByParticipant[id]) continue if (holesByParticipant[id]) continue
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${id}/holes`).then((data) => { getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${id}/holes`).then((data) => {
@ -3060,9 +3076,106 @@ function ScoreTab({
cancelled = true cancelled = true
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [base, roundId, roundParticipants, participantId, holesByParticipant]) }, [base, roundId, roundParticipants, participantId, holesByParticipant, showFullScorecard, visibleParticipants])
const holes = participantId ? (holesByParticipant[participantId] ?? null) : null // Offline-kø for score-føring (ADR-104, punkt 1 av parity-gap-listen) --
// speiler round-detail.tsx sitt queueParticipantHoleWrite/
// updateWizardStat-mønster (ADR-028/057/060) nøyaktig, samme delte
// frontend/lib/offline-queue.ts (ingen endring i selve biblioteket).
const [isOnline, setIsOnline] = useState(true)
const [pendingCount, setPendingCount] = useState(0)
const [syncing, setSyncing] = useState(false)
const [pendingHoles, setPendingHoles] = useState<Set<string>>(new Set())
const [conflictNotice, setConflictNotice] = useState<string | null>(null)
// patch.expected_version er allerede satt av TournamentScoringWizard sin
// save() (expected_version: hole.version) -- ingen egen versjonsoppslag
// trengs her, ulikt round-detail.tsx sin variant (som har flere
// kallesteder uten den garantien).
async function queueHoleWrite(participantIdParam: string, holeNumber: number, patch: HolePatch) {
const url = `${base}/rounds/${roundId}/participants/${participantIdParam}/holes/${holeNumber}`
await enqueueWrite({ url, method: "PATCH", body: patch, matchId: roundId })
setPendingHoles((prev) => new Set(prev).add(`${participantIdParam}:${holeNumber}`))
setPendingCount((c) => c + 1)
setHolesByParticipant((prev) => ({
...prev,
[participantIdParam]: (prev[participantIdParam] ?? []).map((h) =>
h.hole_number === holeNumber ? { ...h, ...patch } : h,
),
}))
}
async function flushPending() {
if (syncing) return
setSyncing(true)
try {
const outcomes = await flushQueue(roundId)
if (outcomes.length === 0) return
const syncedParticipants = new Set<string>()
for (const o of outcomes) {
const match = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/)
if (!match) continue
setPendingHoles((prev) => {
const next = new Set(prev)
next.delete(`${match[1]}:${match[2]}`)
return next
})
syncedParticipants.add(match[1])
}
const failed = outcomes.filter((o) => !o.ok)
if (failed.length > 0) {
// ADR-057: en avvist kø-synk (f.eks. en versjonskonflikt) er
// forbigående -- IKKE onError, som ville tatt over hele siden.
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 getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${id}/holes`).then((data) => {
setHolesByParticipant((prev) => ({ ...prev, [id]: data ?? [] }))
})
}
setRefreshTick((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])
// Køede skrivinger fra en tidligere økt (f.eks. siden ble lukket mens
// offline) -- samme engangs-sjekk-ved-mount-mønster som round-detail.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])
// Full statistikkdybde (migrasjon 075, ADR-071) -- ÉN batched save() ved // Full statistikkdybde (migrasjon 075, ADR-071) -- ÉN batched save() ved
// siste steg (ikke PATCH per felt, se ADR-078). Skriver alltid inn i // siste steg (ikke PATCH per felt, se ADR-078). Skriver alltid inn i
@ -3071,13 +3184,34 @@ function ScoreTab({
// siden flere spilleres ark nå kan være i bruk etter hverandre uten // siden flere spilleres ark nå kan være i bruk etter hverandre uten
// remount). // remount).
async function updateHoleFull(participantIdParam: string, holeNumber: number, patch: HolePatch) { async function updateHoleFull(participantIdParam: string, holeNumber: number, patch: HolePatch) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantIdParam}/holes/${holeNumber}`, { if (!navigator.onLine) {
await queueHoleWrite(participantIdParam, holeNumber, patch)
return
}
let res: Response
try {
res = await fetch(`${base}/rounds/${roundId}/participants/${participantIdParam}/holes/${holeNumber}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(patch), body: JSON.stringify(patch),
}) })
} catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
// for å tape registreringen, se ADR-028.
await queueHoleWrite(participantIdParam, holeNumber, patch)
return
}
if (!res.ok) { if (!res.ok) {
if (res.status === 409) {
// En annen enhet skrev til akkurat dette hullet først (ADR-057) --
// vis det tydelig og hent inn den andres versjon.
setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
void getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantIdParam}/holes`).then((data) => {
setHolesByParticipant((prev) => ({ ...prev, [participantIdParam]: data ?? [] }))
})
return
}
throw new Error("Klarte ikke å lagre.") throw new Error("Klarte ikke å lagre.")
} }
const updated: ApiHole = await res.json() const updated: ApiHole = await res.json()
@ -3130,10 +3264,6 @@ function ScoreTab({
}, [activeHole, chain, holesByParticipant, flightHoleNumbers]) }, [activeHole, chain, holesByParticipant, flightHoleNumbers])
const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null) const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
// "Vis hele scorekortet" (ADR-099, steg 2) -- kollapset som standard,
// samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid-
// splitt.
const [showFullScorecard, setShowFullScorecard] = useState(false)
// Rundespesifikk kommentartråd (ADR-102) -- kollapset som standard, // Rundespesifikk kommentartråd (ADR-102) -- kollapset som standard,
// samme mønster som "Vis hele scorekortet". // samme mønster som "Vis hele scorekortet".
const [showComments, setShowComments] = useState(false) const [showComments, setShowComments] = useState(false)
@ -3213,6 +3343,47 @@ function ScoreTab({
))} ))}
</div> </div>
{/* Offline scoreføring (ADR-104, punkt 1) -- synlig uansett hvilken
underseksjon som er åpen, offline-tilstanden er global for hele
fanen. Samme banner-mønster som round-detail.tsx (ADR-028). */}
{!isOnline && (
<div className="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 nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="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"} 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
</button>
)}
</div>
)}
{conflictNotice && (
<div className="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>{conflictNotice}</span>
<button
type="button"
onClick={() => setConflictNotice(null)}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
)}
{roundParticipants.length === 0 ? ( {roundParticipants.length === 0 ? (
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">
Ingen deltakere i denne runden ennå -- legg til under «Oppsett». Ingen deltakere i denne runden ennå -- legg til under «Oppsett».
@ -3373,11 +3544,15 @@ function ScoreTab({
/> />
)} )}
{/* Fullt scorekort, spiller-først (steg 1) -- sekundært nå, bak en {/* Fullt flerspiller-scorekort-rutenett (ADR-104, punkt 4 av
bryter, samme fiks som round-detail.tsx sitt "Vis hele parity-gap-listen) -- ekte alle-spillere×alle-hull-rutenett,
scorekortet" (kommentar der: et bredt grid tar for stor plass speiler round-detail.tsx sin ScorecardGrid strukturelt (sticky
i bredden mobil som standardvisning). Ingen ny kode for spillerkolonne, Ut/Inn/Sum), men bruker denne filens EGNE
selve gridet, kun ny plassering. */} classify/scoreMarkClasses-fargespråk (ikke ScorecardGrid sin
lokale ScorecardCell) for visuell konsistens med flight-
kortlisten/HoleGrid over. Erstatter den tidligere pille-rad +
enkelt-spiller-HoleGrid-visningen (samme kollapsbare
container/plassering som før). */}
<div className="overflow-hidden rounded-2xl border border-border bg-card"> <div className="overflow-hidden rounded-2xl border border-border bg-card">
<button <button
type="button" type="button"
@ -3390,25 +3565,6 @@ function ScoreTab({
</button> </button>
{showFullScorecard && ( {showFullScorecard && (
<div className="flex flex-col gap-3 border-t border-border p-3"> <div className="flex flex-col gap-3 border-t border-border p-3">
<div className="flex flex-wrap gap-2">
{visibleParticipants.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setParticipantId(rp.id)}
aria-pressed={rp.id === participantId}
className={cn(
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
rp.id === participantId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{rp.player_name}
</button>
))}
</div>
{myGroupId && ( {myGroupId && (
<button <button
type="button" type="button"
@ -3419,18 +3575,18 @@ function ScoreTab({
</button> </button>
)} )}
{round && holes && ( {round && (
<HoleGrid <TournamentScorecardGrid
base={base} participants={visibleParticipants}
roundId={roundId} holesByParticipant={holesByParticipant}
participantId={participantId} holeOrder={flightHoleNumbers}
holes={holes} activeHole={activeHole}
holeConfig={round.hole_config} hcpFor={hcpFor}
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"} onSelectHole={(n) => setActiveHole(n)}
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""} onOpenEntry={(rpId, n) => {
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null} setActiveHole(n)
ownBagClubs={[]} setWizardPlayerId(rpId)
onUpdateFull={(holeNumber, patch) => updateHoleFull(participantId, holeNumber, patch)} }}
/> />
)} )}
</div> </div>
@ -4385,6 +4541,219 @@ function scoreMarkClasses(kind: ScoreClass): string {
} }
} }
// Fullt flerspiller-scorekort-rutenett (ADR-104, punkt 4 av "Scoring-
// paritet: frittstående runde vs. org-turnering"-gap-listen) -- speiler
// round-detail.tsx sin ScorecardGrid strukturelt (sticky spillerkolonne,
// hull-kolonner gruppert Ut(1-9)/Inn(10-18) på FYSISK hullnummer, Sum-
// kolonne), men MED denne filens egne classify/scoreMarkClasses (ikke
// ScorecardGrid sin lokale ScorecardCell) -- visuell konsistens med
// flight-kortlisten over. Ingen sides/isTwoSided/formatResult-gren --
// individuelle turneringer har ALDRI et side-begrep (bekreftet: den
// grenen i ScorecardGrid er alltid death-path for ren slagspill uansett).
// Ingen "plukket opp"/PU-merkelapp -- tournament_round_hole har ingen
// tilsvarende kolonne.
function TournamentScorecardGrid({
participants,
holesByParticipant,
holeOrder,
activeHole,
hcpFor,
onSelectHole,
onOpenEntry,
}: {
participants: ApiRoundParticipant[]
holesByParticipant: Record<string, ApiHole[]>
holeOrder: number[]
activeHole: number | null
hcpFor: (rp: ApiRoundParticipant) => number | null
onSelectHole: (holeNumber: number) => void
onOpenEntry: (participantId: string, holeNumber: number) => void
}) {
const holeMeta: Record<number, { par: 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 }
}
}
// Ut/Inn er alltid gruppert på FYSISK hullnummer (tradisjonell
// scorekort-konvensjon), ikke spillerekkefølge -- samme regel som
// round-detail.tsx sin ScorecardGrid. Kun meningsfullt å splitte for en
// 18-hulls runde.
const showSplit = holeOrder.length === 18
const front = holeOrder.filter((n) => n <= 9)
const back = holeOrder.filter((n) => n >= 10)
function sumFor(participantId: string, numbers: number[]): { strokes: number; par: number } | null {
const list = holesByParticipant[participantId]
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!.gross_strokes !== null)
if (played.length === 0) return null
return {
strokes: played.reduce((sum, h) => sum + (h.gross_strokes as number), 0),
par: played.reduce((sum, h) => sum + h.par, 0),
}
}
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
return (
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="border-separate border-spacing-0">
<colgroup>
<col style={{ width: 132 }} />
{holeOrder.map((n) => (
<col key={n} style={{ width: 48 }} />
))}
{showSplit && <col style={{ width: 56 }} />}
{showSplit && <col style={{ width: 56 }} />}
<col style={{ width: 64 }} />
</colgroup>
<thead>
<tr>
<th className="sticky left-0 z-10 border-b border-border bg-card p-2 text-left text-xs font-bold text-muted-foreground">
Spiller
</th>
{holeOrder.map((n) => (
<th key={n} className="border-b border-border p-0 text-center">
<button
type="button"
onClick={() => onSelectHole(n)}
aria-current={n === activeHole ? "true" : undefined}
aria-label={`Hull ${n}`}
className={cn(
"flex h-10 w-full items-center justify-center text-sm font-bold tabular-nums transition-colors",
n === activeHole
? "bg-primary text-primary-foreground"
: "bg-card text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
</th>
))}
{showSplit && (
<th className="border-b border-border p-2 text-center text-xs font-bold text-muted-foreground">Ut</th>
)}
{showSplit && (
<th className="border-b border-border p-2 text-center text-xs font-bold text-muted-foreground">Inn</th>
)}
<th className="border-b border-border p-2 text-center text-xs font-bold text-muted-foreground">Sum</th>
</tr>
<tr>
<td className="sticky left-0 z-10 border-b border-border bg-card p-2 text-xs font-semibold text-muted-foreground">
Par
</td>
{holeOrder.map((n) => (
<td
key={n}
className="border-b border-border p-1 text-center text-xs font-semibold tabular-nums text-muted-foreground"
>
{holeMeta[n]?.par ?? ""}
</td>
))}
{showSplit && (
<td className="border-b border-border p-1 text-center text-xs font-semibold tabular-nums text-muted-foreground">
{front.reduce((s, n) => s + (holeMeta[n]?.par ?? 0), 0)}
</td>
)}
{showSplit && (
<td className="border-b border-border p-1 text-center text-xs font-semibold tabular-nums text-muted-foreground">
{back.reduce((s, n) => s + (holeMeta[n]?.par ?? 0), 0)}
</td>
)}
<td className="border-b border-border p-1 text-center text-xs font-semibold tabular-nums text-muted-foreground">
{parRowTotal}
</td>
</tr>
</thead>
<tbody>
{participants.map((rp) => {
const frontSum = sumFor(rp.id, front)
const backSum = sumFor(rp.id, back)
const totalSum = sumFor(rp.id, holeOrder)
const hcp = hcpFor(rp)
return (
<tr key={rp.id}>
<td className="sticky left-0 z-10 border-b border-border bg-card p-0">
<button
type="button"
onClick={() => activeHole !== null && onOpenEntry(rp.id, activeHole)}
disabled={activeHole === null}
className="flex min-h-11 w-full flex-col items-start justify-center gap-0.5 px-2 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:pointer-events-none"
>
<span className="flex items-center gap-1.5 truncate text-sm font-bold text-foreground">
{rp.player_name}
{rp.is_self && (
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-primary">
Deg
</span>
)}
</span>
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
HCP {hcp !== null ? hcp.toFixed(1).replace(".", ",") : ""}
</span>
</button>
</td>
{holeOrder.map((n) => {
const hole = holesByParticipant[rp.id]?.find((h) => h.hole_number === n)
const gross = hole?.gross_strokes ?? null
const net =
gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined
? gross - hole.strokes_received
: gross
const diff = net !== null && hole ? net - hole.par : null
const kind = diff !== null ? classify(diff) : null
return (
<td key={n} className="border-b border-border p-1 text-center">
<button
type="button"
onClick={() => onOpenEntry(rp.id, n)}
aria-label={`${rp.player_name}, hull ${n}, ${gross !== null ? `${gross} slag` : "ikke registrert"}`}
className={cn(
"mx-auto flex size-9 items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
kind
? scoreMarkClasses(kind)
: "rounded-[4px] border-2 border-dashed border-border text-muted-foreground hover:bg-accent/50",
)}
>
{gross ?? ""}
</button>
</td>
)
})}
{showSplit && (
<td className="border-b border-border p-1 text-center text-sm font-bold tabular-nums text-foreground">
{frontSum !== null ? frontSum.strokes : ""}
</td>
)}
{showSplit && (
<td className="border-b border-border p-1 text-center text-sm font-bold tabular-nums text-foreground">
{backSum !== null ? backSum.strokes : ""}
</td>
)}
<td className="border-b border-border p-1 text-center">
<span className="block text-sm font-extrabold tabular-nums text-foreground">
{totalSum !== null ? totalSum.strokes : ""}
</span>
{totalSum !== null && (
<span className="block text-[10px] font-semibold tabular-nums text-muted-foreground">
{totalSum.strokes - totalSum.par >= 0 ? "+" : ""}
{totalSum.strokes - totalSum.par}
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// --- Full statistikkregistrering per hull (migrasjon 075, ADR-071) -------- // --- Full statistikkregistrering per hull (migrasjon 075, ADR-071) --------
// Enkelt-skjerm (IKKE en flerstegs-veiviser som ScoringWizard i round- // Enkelt-skjerm (IKKE en flerstegs-veiviser som ScoringWizard i round-
// detail.tsx -- HoleGrid åpner ETT hull om gangen for ÉN allerede valgt // detail.tsx -- HoleGrid åpner ETT hull om gangen for ÉN allerede valgt
@ -4857,127 +5226,6 @@ function TournamentScoringWizard({
) )
} }
function HoleGrid({
base,
roundId,
participantId,
holes,
holeConfig,
statLevel,
playerName,
strokesReceived,
ownBagClubs,
onUpdateFull,
}: {
base: string
roundId: string
participantId: string
holes: ApiHole[]
holeConfig: ApiRound["hole_config"]
statLevel: "strokes_only" | "strokes_and_putts" | "full"
playerName: string
strokesReceived: (holeNumber: number) => number | null
ownBagClubs: string[]
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
}) {
// Full statistikkdybde (migrasjon 075, ADR-071), utvidet ADR-099 (2026-
// 08-22): ÉN wizard for alle tre statLevel-nivåer nå (strokes_only fikk
// tidligere kun et bart inline-tallfelt her -- fjernet, samme picker-
// opplevelse som resten av appen for alle nivåer, se TournamentScoringWizard).
const [sheetHole, setSheetHole] = useState<number | null>(null)
const numbers = holeNumbersFor(holeConfig)
const byNumber = new Map(holes.map((h) => [h.hole_number, h]))
const played = holes.filter((h) => h.gross_strokes !== null)
const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null
const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
function openEditor(holeNumber: number) {
setSheetHole(holeNumber)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="overflow-x-auto">
<div className="inline-grid min-w-full gap-y-1" style={{ gridTemplateColumns: `repeat(${numbers.length}, minmax(2.5rem, 1fr))` }}>
{numbers.map((n) => (
<span key={`num-${n}`} className="text-center text-[11px] font-bold text-muted-foreground">
{n}
</span>
))}
{numbers.map((n) => (
<span key={`par-${n}`} className="text-center text-[10px] font-semibold tabular-nums text-muted-foreground">
Par {byNumber.get(n)?.par ?? ""}
</span>
))}
{numbers.map((n) => {
const hole = byNumber.get(n)
const gross = hole?.gross_strokes ?? null
const net = gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined
? gross - hole.strokes_received
: gross
const diff = net !== null && hole ? net - hole.par : null
const kind = diff !== null ? classify(diff) : null
return (
<button
key={`cell-${n}`}
type="button"
onClick={() => openEditor(n)}
aria-label={
gross !== null
? `Hull ${n}, ${gross} slag, trykk for å endre`
: `Hull ${n}, ikke registrert, trykk for å registrere`
}
className={cn(
"mx-auto flex size-9 items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
kind
? scoreMarkClasses(kind)
: "rounded-[4px] border-2 border-dashed border-border text-muted-foreground hover:bg-accent/50",
)}
>
{gross ?? ""}
</button>
)
})}
</div>
</div>
{totalGross !== null && totalPar !== null && (
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
<span className="font-semibold text-muted-foreground">
{played.length} av {numbers.length} hull spilt
</span>
<span className="font-extrabold tabular-nums text-foreground">
{totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""}
{totalGross - totalPar} til par)
</span>
</div>
)}
{sheetHole !== null && byNumber.get(sheetHole) && (
<TournamentScoringWizard
hole={byNumber.get(sheetHole)!}
playerName={playerName}
statLevel={statLevel}
strokesReceived={strokesReceived(sheetHole)}
ownBagClubs={ownBagClubs}
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
players={[{ id: participantId, playerName, registered: (byNumber.get(sheetHole)?.gross_strokes ?? null) !== null }]}
activePlayerId={participantId}
onSwitchPlayer={() => {}}
hasNextPlayer={false}
nextPlayerName={null}
onSave={(patch) => onUpdateFull(sheetHole, patch)}
onAdvance={() => setSheetHole(null)}
onClose={() => setSheetHole(null)}
/>
)}
</div>
)
}
// ========================================================================= // =========================================================================
// Leaderboard-fane // Leaderboard-fane
// ========================================================================= // =========================================================================