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:
parent
8c3cdfff2e
commit
629197d22a
4 changed files with 559 additions and 185 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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 på 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"} 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>
|
||||||
|
)}
|
||||||
|
{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 på 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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue