Fiks HCP-komma app-bredt, utdatert deltakertabell, zebra-striper i spillerpool

HCP: type="number" blokkerte komma-tegnet i nettleseren før JS-parsingen
noensinne så det; byttet til text+inputMode=decimal og lagt til manglende
.replace(",",".") i tre lagre-stier. Deltakertabell: "Rediger i
spillerpoolen" er en full sidenavigasjon, bfcache kunne gjenopprette
turneringssiden i utdatert tilstand -- løst med stille bakgrunns-refresh
ved visibilitychange/pageshow. Spillerpool: zebra-striper lagt tilbake
oppå rutenett-stilen fra forrige runde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-19 09:50:56 +02:00
parent 5dca409270
commit 48f238d158
7 changed files with 164 additions and 45 deletions

View file

@ -13063,3 +13063,50 @@ Neste steg:
**Rullet ut 2026-08-19** -- bruker bekreftet. Ren frontend.
`docker compose build teecup_frontend && up -d`, rene logger,
200 OK.
129. **HCP-komma, utdatert deltakertabell etter spillerpool-redigering,
zebra-striper -- 2026-08-19.** Tre bruker-rapporterte ting i samme
runde. (1) "Er det relevant om det registreres HCP med komma heller
enn punktum? Jeg vil at det skal være irrelevant." -- undersøkt
grundig: `tournament-players-table.tsx` og to felt i `round-
detail.tsx` ("guest-hcp"/"edit-hcp-{id}") brukte `type="number"`,
som i de fleste nettlesere BLOKKERER komma-tegnet før JS i det hele
tatt ser det -- selv om lagre-parsingen andre steder allerede gjorde
`.replace(",", ".")`, hjalp det ikke når kommaet aldri kom inn i
feltet. Byttet til `type="text"` + `inputMode="decimal"` (samme
mønster som account-settings.tsx allerede brukte korrekt). I
TILLEGG manglet selve `.replace(",", ".")`-konverteringen helt i
tre lagre-stier: `tournament-players-table.tsx` sin `commitHcp`,
og to steder i `org-player-roster-panel.tsx` samt ett i `player-
import-panel.tsx` (spillerpool-lagring/CSV-import). Alle rettet,
HCP-komma er nå konsistent håndtert app-bredt. (2) "Jeg kan ikke se
at deltagertabellen blir oppdatert når det er lagret endringer i
spillerpool-tabellen" -- rotårsak: "Rediger i spillerpoolen" er en
ekte `<a href>` (full sidenavigasjon), og nettleserens bfcache kan
gjenopprette turneringssiden i EKSAKT samme (utdaterte) tilstand
ved tilbake-navigasjon uten å kjøre noen React-effekter på nytt.
Løst med en stille bakgrunns-refresh (`visibilitychange` +
`pageshow` med `persisted`-sjekk) i BÅDE `IndividualTournamentDetail`
(deltakere/runder/baner/klasser) og `SetupTab` (spillerpool) --
hovedlasteren løftet ut av `useEffect` til en gjenbrukbar
`loadTournamentData()`, med en delt `mountedRef` i stedet for en
per-kall `cancelled`-variabel siden den nå kalles fra to steder.
(3) "Kan du kjøre annenhver rad-bakgrunn? Hvit og lys grå?" --
spillerpool-tabellen (`player-import-view.tsx`) fikk tilbake zebra-
striping (`bg-card`/`bg-muted/40`) oppå rutenett-stilen fra forrige
runde -- den sticky navnekolonnen får nå samme bakgrunn som resten
av raden i stedet for alltid `bg-card`, ellers ville den sett
frakoblet ut fra stripen bak seg ved sidescroll.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Scratch
bekrefter: komma faktisk skrivbart og korrekt konvertert (testet
både i spillerpool-tabellen og den nye spillertabellen -- sistnevnte
viste "18,9" lagret som det reelle tallet 18.9, ikke reversert til
forrige verdi), zebra-striper synlige. Punkt (2) er IKKE scratch-
reprodusert (bfcache-oppførsel er vanskelig å simulere i en
hodeløs devtools-økt) -- vurdert tilstrekkelig dekket av at
rotårsaken er tydelig identifisert og fiksen er et etablert,
velprøvd mønster (visibilitychange/pageshow) for akkurat dette
problemet.
**Rullet ut:** venter på bekreftelse.

View file

@ -444,38 +444,74 @@ export function IndividualTournamentDetail({
}
}
// Løftet ut av useEffect (2026-08-19) -- brukes nå fra to steder: den
// vanlige innlastingen ved mount, OG en "frisk opp når fanen blir synlig
// igjen"-lytter under. Bruker: "Jeg kan ikke se at deltagertabellen blir
// oppdatert når det er lagret endringer i spillerpool-tabellen" -- rot-
// årsak: "Rediger i spillerpoolen" er en full sidenavigasjon (ekte
// <a href>, ikke Next-Link), og nettleserens bfcache gjenoppretter siden
// i EKSAKT samme (utdaterte) tilstand ved tilbake-navigasjon, uten å
// kjøre noen effekter på nytt -- `mountedRef` brukes i stedet for en
// per-kall `cancelled`-variabel siden funksjonen nå kalles fra flere
// uavhengige steder.
const mountedRef = useRef(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
getJson<ApiTournamentClass[]>(`${base}/classes`),
])
if (cancelled) return
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
if (!mine) {
setError("Fant ikke turneringen.")
return
}
setTournament(mine)
setParticipants(participantsData ?? [])
setRounds(roundsData ?? [])
setCourses(coursesData ?? [])
setClasses(classesData ?? [])
} catch {
if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
load()
mountedRef.current = true
return () => {
cancelled = true
mountedRef.current = false
}
}, [])
async function loadTournamentData() {
try {
const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
getJson<ApiTournamentClass[]>(`${base}/classes`),
])
if (!mountedRef.current) return
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
if (!mine) {
setError("Fant ikke turneringen.")
return
}
setTournament(mine)
setParticipants(participantsData ?? [])
setRounds(roundsData ?? [])
setCourses(coursesData ?? [])
setClasses(classesData ?? [])
} catch {
if (mountedRef.current) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (mountedRef.current) setLoading(false)
}
}
useEffect(() => {
loadTournamentData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, tournamentId, base])
// Stille bakgrunns-refresh (ingen loading-spinner -- setLoading(false)
// er allerede satt) når fanen/vinduet blir synlig igjen, ELLER siden
// gjenopprettes fra bfcache (pageshow med persisted=true er det
// spesifikke signalet for akkurat det tilfellet).
useEffect(() => {
function handleVisible() {
if (document.visibilityState === "visible") loadTournamentData()
}
function handlePageShow(e: PageTransitionEvent) {
if (e.persisted) loadTournamentData()
}
document.addEventListener("visibilitychange", handleVisible)
window.addEventListener("pageshow", handlePageShow)
return () => {
document.removeEventListener("visibilitychange", handleVisible)
window.removeEventListener("pageshow", handlePageShow)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, tournamentId, base])
async function updateStatus(newStatus: TournamentStatus) {
@ -1218,6 +1254,26 @@ function SetupTab({
}
}, [organizationId])
// Samme "frisk opp ved synlig igjen"-behov som gjelder hele siden
// (se IndividualTournamentDetail sin tilsvarende lytter) -- "Rediger i
// spillerpoolen" er en full sidenavigasjon, og bfcache kan gjenopprette
// denne komponenten i utdatert tilstand ved tilbake-navigasjon.
useEffect(() => {
function handleVisible() {
if (document.visibilityState === "visible") refreshPool()
}
function handlePageShow(e: PageTransitionEvent) {
if (e.persisted) refreshPool()
}
document.addEventListener("visibilitychange", handleVisible)
window.addEventListener("pageshow", handlePageShow)
return () => {
document.removeEventListener("visibilitychange", handleVisible)
window.removeEventListener("pageshow", handlePageShow)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId])
// Rundedeltakelse-tabellen (ADR-093, "Spillere"-steget) -- lat innlasting,
// KUN når steget faktisk vises (ikke eagerly ved sideinnlasting), av alle
// rundenes rundedeltakere OG alle unike baners utslag samtidig. Flere

View file

@ -200,7 +200,10 @@ export function OrgPlayerRosterPanel({
if (field === "gender") {
patch.gender = toBackendGender(row.gender)
} else if (field === "handicap_index") {
patch.handicap_index = row.handicap_index.trim() ? Number(row.handicap_index) : null
// Komma-desimal skal fungere likt som punktum (2026-08-19).
patch.handicap_index = row.handicap_index.trim()
? Number(row.handicap_index.replace(",", "."))
: null
} else if (field === "birth_date") {
patch.birth_date = row.birth_date.trim() || null
} else {
@ -232,7 +235,7 @@ export function OrgPlayerRosterPanel({
body: JSON.stringify({
display_name: row.display_name.trim(),
email: row.email.trim() || null,
handicap_index: row.handicap_index.trim() ? Number(row.handicap_index) : null,
handicap_index: row.handicap_index.trim() ? Number(row.handicap_index.replace(",", ".")) : null,
gender: toBackendGender(row.gender),
birth_date: row.birth_date.trim() || null,
mobile: row.mobile.trim() || null,

View file

@ -299,7 +299,7 @@ export function PlayerImportPanel({
data: {
display_name: r.display_name.trim(),
email: r.email.trim() || null,
handicap_index: r.handicap_index.trim() ? Number(r.handicap_index) : null,
handicap_index: r.handicap_index.trim() ? Number(r.handicap_index.replace(",", ".")) : null,
gender: toBackendGender(r.gender),
birth_date: r.birth_date.trim() || null,
mobile: r.mobile.trim() || null,

View file

@ -496,7 +496,15 @@ function EditTable({
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
{rows.map((row, rowIndex) => {
// Annenhver rad-bakgrunn (2026-08-19, bruker: "hvit og lys
// grå") -- ren pekepinn for å lese på tvers av en rad i en
// bred tabell, samme rutenett-linjer som ellers. Den sticky
// første cellen må få SAMME bakgrunn som resten av raden
// (ikke alltid bg-card), ellers ser den frakoblet ut fra
// stripen bak den ved sidescroll.
const rowBg = rowIndex % 2 === 1 ? "bg-muted/40" : "bg-card"
return (
<tr
key={row.key}
// data-row-key (2026-08-18): rent orkestrerings-anker, IKKE
@ -505,10 +513,10 @@ function EditTable({
// scrolle til riktig rad uten å måtte utvide selve
// props-kontrakten eller endre noe synlig her.
data-row-key={row.key}
className="group h-9 hover:bg-accent/20"
className={cn("group h-9 hover:bg-accent/20", rowBg)}
>
{editFields.map((field, i) => (
<td key={field} className={cn(TD, i === 0 && "sticky left-0 z-10 bg-card group-hover:bg-accent/20")}>
<td key={field} className={cn(TD, i === 0 && `sticky left-0 z-10 ${rowBg} group-hover:bg-accent/20`)}>
{field === "gender" ? (
<select
aria-label={`${FIELD_LABELS[field]} for rad ${rowIndex + 1}`}
@ -577,7 +585,8 @@ function EditTable({
</button>
</td>
</tr>
))}
)
})}
</tbody>
</table>
</div>

View file

@ -4748,9 +4748,11 @@ function AddGuestForm({
</Label>
<Input
id="guest-hcp"
// type="text" (ikke "number") -- komma-desimal ("12,4") blokkeres
// av native number-inputs i de fleste nettlesere før JS-parsingen
// (som allerede håndterer komma korrekt) i det hele tatt ser den.
// 2026-08-19.
inputMode="decimal"
type="number"
step="0.1"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
placeholder="F.eks. 18"
@ -5392,9 +5394,9 @@ function EditParticipantPanel({
</Label>
<Input
id={`edit-hcp-${player.id}`}
// type="text" (ikke "number") -- se guest-hcp over, samme
// begrunnelse. 2026-08-19.
inputMode="decimal"
type="number"
step="0.1"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
placeholder="F.eks. 18"

View file

@ -361,7 +361,9 @@ function PlayerRow({
onSetHandicap(player.participantId, null)
return
}
const num = Number(trimmed)
// Komma-desimal (norsk skrivemåte, "12,4") skal fungere likt som punktum
// -- 2026-08-19, bruker: "jeg vil at det skal være irrelevant".
const num = Number(trimmed.replace(",", "."))
if (Number.isNaN(num)) {
// Revert bad input back to the last known good value.
setHcpDraft(player.handicapSnapshot === null ? "" : String(player.handicapSnapshot))
@ -382,11 +384,11 @@ function PlayerRow({
<td className={TD}>
<input
type="number"
// type="text" (ikke "number") -- native number-inputs blokkerer
// komma-tegnet i de fleste nettlesere FØR det når JS i det hele
// tatt, uansett hva onBlur-parsingen gjør. inputMode="decimal"
// beholdes for riktig mobiltastatur. 2026-08-19.
inputMode="decimal"
step={0.1}
min={-10}
max={54}
aria-label={`Handicap for ${player.playerName}`}
value={hcpDraft}
onChange={(e) => setHcpDraft(e.target.value)}