Gjør spillertabellen mer regneark-aktig, fyll full bredde (tillegg til ADR-094)

Bruker sammenlignet med et regneark og påpekte unødvendig sidescroll
på bred skjerm -- tettet opp med rutenett-stil og en colgroup som lar
rundekolonnene fylle resten av bredden. Samme props-kontrakt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-19 08:37:15 +02:00
parent 17589e1707
commit 072638dc37
3 changed files with 160 additions and 104 deletions

View file

@ -9607,6 +9607,26 @@ typesjekket, gitt øktens allerede betydelige lengde.
compose build teecup_api teecup_frontend && up -d` for begge, rene compose build teecup_api teecup_frontend && up -d` for begge, rene
logger, 200 OK (inkl. ny `/hjelp`-rute). logger, 200 OK (inkl. ny `/hjelp`-rute).
**Tillegg 2026-08-19 -- visuell revisjon av spillertabellen, rett etter
utrulling.** Bruker (skjermdump av den ekte, live tabellen med 51
spillere): "skulle ønske tabellen var mer 'regneark-aktig'" og "hvorfor
må jeg scrolle horisontalt når jeg har så mye ledig skjermplass?" --
gyldig kritikk, bekreftet i skjermdumpen (brede kort-aktige celler med
mye padding, faste brede kolonner som sendte rundekolonnene utenfor
synlig område selv på en bred skjerm). Bruker tok dette direkte til V0
selv (ikke via meg) og lastet opp svaret. Ny versjon av `tournament-
players-table.tsx`: rutenett-linjer i stedet for kort-følelse
(`border-b border-r` per celle, ingen egen kant per kontroll -- kun
hover/fokus lyser opp), lavere radhøyde (`h-9`), mindre tekst
(`text-[13px]`), OG et `<colgroup>` som gir faste, smale bredder til
de korte kolonnene (HCP/kjønn/alder) mens rundekolonnene IKKE får noen
fast bredde -- de fordeler resten av den tilgjengelige bredden seg
imellom, så tabellen fyller skjermen i stedet for å la dem presses
utenfor. **Props-kontrakten er UENDRET** (samme typer, samme callback-
navn) -- ren visuell omskriving, ingen endring i `SetupTab` sin
sammenkobling nødvendig. Verifisert i ny lokal scratch-visning (bred
skjerm bekreftet fylt uten sidescroll med 3 runde-kolonner, lys+mørk).
--- ---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)

View file

@ -13020,3 +13020,20 @@ Neste steg:
**Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon. **Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon.
`docker compose build teecup_api teecup_frontend && up -d` for `docker compose build teecup_api teecup_frontend && up -d` for
begge, rene logger, 200 OK (inkl. ny `/hjelp`-rute). begge, rene logger, 200 OK (inkl. ny `/hjelp`-rute).
127. **Spillertabellen gjort mer "regneark-aktig" + fyller full bredde --
2026-08-19 (tillegg til ADR-094).** Bruker, med skjermdump av den
ekte 51-spiller-tabellen: "skulle ønske tabellen var mer 'regneark-
aktig'" og "hvorfor må jeg scrolle horisontalt når jeg har så mye
ledig skjermplass?" -- gyldig, bekreftet i skjermdumpen. Bruker tok
dette direkte til V0 selv og lastet opp svaret. Ny versjon:
rutenett-linjer i stedet for kort-følelse, lavere radhøyde, mindre
tekst, og et `<colgroup>` som gir faste smale bredder til korte
kolonner mens rundekolonnene fordeler resten av bredden seg
imellom -- fyller skjermen i stedet for fast sidescroll. Props-
kontrakten uendret, ingen endring i `SetupTab` sin sammenkobling.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Ny scratch-
visning bekrefter bred skjerm fylt uten sidescroll, lys+mørk.
**Rullet ut:** venter på bekreftelse.

View file

@ -64,9 +64,19 @@ const STATUSES: { label: string; value: TournamentPlayerRow["status"] }[] = [
{ label: "DNS", value: "dns" }, { label: "DNS", value: "dns" },
] ]
// Shared control styling so every inline field feels like one family. // Spreadsheet cell control: flat, fills the whole cell, no border of its own.
const CONTROL = // The grid lines define the structure; controls only light up on hover/focus.
"h-9 rounded-lg border border-border bg-background px-2.5 text-sm text-foreground transition-all duration-200 ease-in-out hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" const CELL =
"h-full w-full border-0 bg-transparent px-2 py-1.5 text-[13px] leading-tight text-foreground outline-none transition-colors hover:bg-accent/40 focus-visible:bg-background focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
// Compact header control (round default-tee select).
const HEAD_CELL =
"h-7 w-full rounded-md border border-border bg-background px-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:border-primary/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
// Shared cell frame: thin grid lines, zero padding (the control supplies it).
const TD = "border-b border-r border-border p-0 align-middle"
const TH =
"border-b border-r border-border bg-muted px-2 py-2 text-left text-[11px] font-bold uppercase tracking-wide text-muted-foreground"
function computeAge(birthDate: string): number | null { function computeAge(birthDate: string): number | null {
const birth = new Date(birthDate) const birth = new Date(birthDate)
@ -133,34 +143,49 @@ export function TournamentPlayersTable({
</div> </div>
)} )}
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-md shadow-black/8"> <div className="overflow-x-auto rounded-xl border border-border bg-card shadow-sm shadow-black/5">
<table className="w-full border-collapse text-left"> {/* Fluid layout: the table fills the available width instead of leaving
dead space and pushing round columns off-screen. */}
<table className="w-full border-collapse text-left [&_td]:last:border-r-0 [&_th]:last:border-r-0">
<colgroup>
<col className="w-[13rem]" />
<col className="w-[5.5rem]" />
{showClassColumn && <col className="w-[9rem]" />}
<col className="w-[9rem]" />
<col className="w-[7rem]" />
<col className="w-[7rem]" />
<col className="w-[10rem]" />
{rounds.map((r) => (
<col key={r.id} />
))}
<col className="w-[5.5rem]" />
</colgroup>
<thead> <thead>
<tr className="divide-x divide-border border-b border-border"> <tr>
<th <th
scope="col" scope="col"
className="sticky left-0 z-20 bg-card px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground" className={`${TH} sticky left-0 top-0 z-30`}
> >
Spiller Spiller
</th> </th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20 text-right`}>
Hcp Hcp
</th> </th>
{showClassColumn && ( {showClassColumn && (
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20`}>
Klasse Klasse
</th> </th>
)} )}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20`}>
Statistikknivå Statistikknivå
</th> </th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20`}>
Status Status
</th> </th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20`}>
Kjønn Kjønn
</th> </th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20`}>
Alder Alder
</th> </th>
{rounds.map((round) => { {rounds.map((round) => {
@ -174,36 +199,33 @@ export function TournamentPlayersTable({
<th <th
key={round.id} key={round.id}
scope="col" scope="col"
className="min-w-52 px-4 py-3 align-top text-xs font-bold uppercase tracking-wide text-muted-foreground" className={`${TH} sticky top-0 z-20 min-w-[9.5rem] align-top`}
> >
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-2">
<span>{round.label}</span> <span>{round.label}</span>
{busy && ( {busy && (
<span className="flex items-center gap-1 normal-case text-primary"> <span className="flex items-center gap-1 normal-case text-primary">
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" /> <Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
<span className="text-[11px] font-semibold">Oppdaterer</span> <span className="text-[10px] font-semibold">Oppdaterer</span>
</span> </span>
)} )}
</div> </div>
<label className="flex flex-col gap-1 normal-case"> <select
<span className="text-[11px] font-semibold text-muted-foreground">Standardutslag</span> aria-label={`Standardutslag for ${round.label}`}
<select className={HEAD_CELL}
aria-label={`Standardutslag for ${round.label}`} value={defaultTees[round.id] ?? ""}
className={CONTROL} disabled={busy}
value={defaultTees[round.id] ?? ""} onChange={(e) =>
disabled={busy} setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
onChange={(e) => }
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value })) >
} {round.tees.map((tee) => (
> <option key={tee.id} value={tee.id}>
{round.tees.map((tee) => ( {tee.name}
<option key={tee.id} value={tee.id}> </option>
{tee.name} ))}
</option> </select>
))}
</select>
</label>
<BulkCheckbox <BulkCheckbox
checked={allAssigned} checked={allAssigned}
indeterminate={someAssigned} indeterminate={someAssigned}
@ -217,15 +239,13 @@ export function TournamentPlayersTable({
</th> </th>
) )
})} })}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"> <th scope="col" className={`${TH} sticky top-0 z-20 text-center`}>
Spillerpool <span className="sr-only">Handlinger</span>
</th> <span aria-hidden="true">···</span>
<th scope="col" className="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
Fjern
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody>
{players.map((player) => ( {players.map((player) => (
<PlayerRow <PlayerRow
key={player.participantId} key={player.participantId}
@ -273,16 +293,16 @@ function BulkCheckbox({
if (ref.current) ref.current.indeterminate = indeterminate && !checked if (ref.current) ref.current.indeterminate = indeterminate && !checked
}, [indeterminate, checked]) }, [indeterminate, checked])
return ( return (
<label className="flex items-center gap-2 normal-case"> <label className="flex items-center gap-1.5 normal-case">
<input <input
ref={ref} ref={ref}
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="size-4 rounded border-border text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/> />
<span className="text-[11px] font-semibold text-muted-foreground">{label}</span> <span className="text-[10px] font-semibold text-muted-foreground">{label}</span>
</label> </label>
) )
} }
@ -352,15 +372,15 @@ function PlayerRow({
} }
return ( return (
<tr className="divide-x divide-border transition-colors duration-200 ease-in-out hover:bg-accent/40"> <tr className="group h-9">
<th <th
scope="row" scope="row"
className="sticky left-0 z-10 bg-card px-4 py-3 text-left align-middle text-sm font-bold text-foreground" className={`${TD} sticky left-0 z-10 bg-card px-2 py-1.5 text-left text-[13px] font-semibold text-foreground group-hover:bg-accent/30`}
> >
{player.playerName} {player.playerName}
</th> </th>
<td className="px-4 py-3 align-middle"> <td className={TD}>
<input <input
type="number" type="number"
inputMode="decimal" inputMode="decimal"
@ -371,15 +391,15 @@ function PlayerRow({
value={hcpDraft} value={hcpDraft}
onChange={(e) => setHcpDraft(e.target.value)} onChange={(e) => setHcpDraft(e.target.value)}
onBlur={commitHcp} onBlur={commitHcp}
className={`${CONTROL} w-20 text-right tabular-nums`} className={`${CELL} text-right tabular-nums`}
/> />
</td> </td>
{showClassColumn && ( {showClassColumn && (
<td className="px-4 py-3 align-middle"> <td className={TD}>
<select <select
aria-label={`Klasse for ${player.playerName}`} aria-label={`Klasse for ${player.playerName}`}
className={`${CONTROL} w-40`} className={CELL}
value={player.classId ?? ""} value={player.classId ?? ""}
onChange={(e) => onSetClass(player.participantId, e.target.value === "" ? null : e.target.value)} onChange={(e) => onSetClass(player.participantId, e.target.value === "" ? null : e.target.value)}
> >
@ -393,10 +413,10 @@ function PlayerRow({
</td> </td>
)} )}
<td className="px-4 py-3 align-middle"> <td className={TD}>
<select <select
aria-label={`Statistikknivå for ${player.playerName}`} aria-label={`Statistikknivå for ${player.playerName}`}
className={`${CONTROL} w-36`} className={CELL}
value={player.statLevel} value={player.statLevel}
onChange={(e) => onSetStatLevel(player.participantId, e.target.value as TournamentPlayerRow["statLevel"])} onChange={(e) => onSetStatLevel(player.participantId, e.target.value as TournamentPlayerRow["statLevel"])}
> >
@ -408,12 +428,10 @@ function PlayerRow({
</select> </select>
</td> </td>
<td className="px-4 py-3 align-middle"> <td className={TD}>
<select <select
aria-label={`Status for ${player.playerName}`} aria-label={`Status for ${player.playerName}`}
className={`${CONTROL} w-28 ${ className={`${CELL} ${statusAbnormal ? "bg-destructive/10 font-semibold text-destructive hover:bg-destructive/15" : ""}`}
statusAbnormal ? "border-destructive/40 bg-destructive/10 text-destructive" : ""
}`}
value={player.status} value={player.status}
onChange={(e) => onSetStatus(player.participantId, e.target.value as TournamentPlayerRow["status"])} onChange={(e) => onSetStatus(player.participantId, e.target.value as TournamentPlayerRow["status"])}
> >
@ -425,10 +443,10 @@ function PlayerRow({
</select> </select>
</td> </td>
<td className="px-4 py-3 align-middle"> <td className={TD}>
<select <select
aria-label={`Kjønn for ${player.playerName}`} aria-label={`Kjønn for ${player.playerName}`}
className={`${CONTROL} w-28`} className={CELL}
value={player.gender ?? ""} value={player.gender ?? ""}
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")} onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
> >
@ -441,16 +459,16 @@ function PlayerRow({
</select> </select>
</td> </td>
<td className="px-4 py-3 align-middle"> <td className={TD}>
<div className="flex items-center gap-2"> <div className="flex items-center">
<input <input
type="date" type="date"
aria-label={`Fødselsdato for ${player.playerName}`} aria-label={`Fødselsdato for ${player.playerName}`}
className={`${CONTROL} w-40`} className={`${CELL} min-w-0`}
value={player.birthDate ?? ""} value={player.birthDate ?? ""}
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)} onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
/> />
<span className="min-w-12 text-xs font-semibold tabular-nums text-muted-foreground"> <span className="shrink-0 whitespace-nowrap px-1.5 text-[11px] font-semibold tabular-nums text-muted-foreground">
{age !== null ? `${age} år` : "Ukjent"} {age !== null ? `${age} år` : "Ukjent"}
</span> </span>
</div> </div>
@ -461,31 +479,27 @@ function PlayerRow({
const assigned = cell?.assigned ?? false const assigned = cell?.assigned ?? false
const busy = busyRoundIds.includes(round.id) const busy = busyRoundIds.includes(round.id)
return ( return (
<td key={round.id} className="px-4 py-3 align-middle"> <td key={round.id} className={TD}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 px-2">
<label className="flex items-center gap-2"> <input
<input type="checkbox"
type="checkbox" checked={assigned}
checked={assigned} disabled={busy}
disabled={busy} aria-label={`${player.playerName} spiller ${round.label}`}
aria-label={`${player.playerName} spiller ${round.label}`} title={assigned ? "Spiller runden" : "Spiller ikke runden"}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked const checked = e.target.checked
const teeId = checked const teeId = checked
? defaultTees[round.id] ?? round.tees[0]?.id ?? "" ? defaultTees[round.id] ?? round.tees[0]?.id ?? ""
: cell?.teeId ?? round.tees[0]?.id ?? "" : cell?.teeId ?? round.tees[0]?.id ?? ""
onToggleRound(round.id, player.participantId, checked, teeId) onToggleRound(round.id, player.participantId, checked, teeId)
}} }}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="size-4 shrink-0 rounded border-border text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/> />
<span className="text-xs font-semibold text-muted-foreground"> {assigned ? (
{assigned ? "Spiller" : "Ikke satt"}
</span>
</label>
{assigned && (
<select <select
aria-label={`Utslag for ${player.playerName} i ${round.label}`} aria-label={`Utslag for ${player.playerName} i ${round.label}`}
className={`${CONTROL} w-28`} className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-1.5 text-xs text-foreground outline-none transition-colors hover:border-primary/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={cell?.teeId ?? ""} value={cell?.teeId ?? ""}
disabled={busy} disabled={busy}
onChange={(e) => onChangeRoundTee(round.id, player.participantId, e.target.value)} onChange={(e) => onChangeRoundTee(round.id, player.participantId, e.target.value)}
@ -496,31 +510,36 @@ function PlayerRow({
</option> </option>
))} ))}
</select> </select>
) : (
<span aria-hidden="true" className="flex-1 text-xs text-muted-foreground/60">
</span>
)} )}
</div> </div>
</td> </td>
) )
})} })}
<td className="px-4 py-3 align-middle"> <td className={TD}>
<a <div className="flex items-center justify-center gap-0.5 px-1">
href={`/organizations/${organizationId}/players?highlight=${player.playerId}`} <a
className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground underline-offset-2 transition-colors duration-200 ease-in-out hover:text-foreground hover:underline" href={`/organizations/${organizationId}/players?highlight=${player.playerId}`}
> aria-label={`Rediger ${player.playerName} i spillerpoolen`}
<ExternalLink aria-hidden="true" className="size-3.5" /> title="Rediger i spillerpoolen"
Rediger i spillerpoolen className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
</a> >
</td> <ExternalLink aria-hidden="true" className="size-4" />
</a>
<td className="px-4 py-3 text-right align-middle"> <button
<button type="button"
type="button" aria-label={`Fjern ${player.playerName}`}
aria-label={`Fjern ${player.playerName}`} title="Fjern spiller"
onClick={() => onRemoveParticipant(player.participantId)} onClick={() => onRemoveParticipant(player.participantId)}
className="inline-flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-95" className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
> >
<Trash2 aria-hidden="true" className="size-4" /> <Trash2 aria-hidden="true" className="size-4" />
</button> </button>
</div>
</td> </td>
</tr> </tr>
) )