Før filendringer

This commit is contained in:
Erol Haagenrud 2026-08-03 14:32:48 +02:00
parent e74fe5d949
commit 242c7a2bfc
6 changed files with 646 additions and 54 deletions

View file

@ -394,7 +394,8 @@
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch >/dev/null 2>&1 *)",
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
"Bash(echo \"tsc exit: $?\")",
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)"
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -6458,6 +6458,68 @@ Ferdig og verifisert:
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `GET /rounds/guests/known` anonymt ga korrekt `401` gjennom hele
produksjonsstacken (ikke en rå 404), `teeoff.no` upåvirket.
- **Score-fanens gjennomgang endte i to runder etter samme rapport
(2026-08-03):** bruker sendte skjermbilde av det brede
ScorecardGrid-et og påpekte at det ikke var intuitivt hvor man skal
trykke for å registrere score. Første prompt til V0 var snevert
(kun synlig trykk-affordance på tomme celler). Bruker ba om en
friere runde 2: "Kan det være mulig å presentere scoreføringsvinduet
på en helt annen måte, uten at informasjon [...] blir borte" +
"det ser generelt ikke særlig bra ut på mobil [...] tar for stor
plass i bredden. Gi V0 et friere spillerom." Ny, åpen prompt skrevet
(mål+datainventar-sjekkliste i stedet for layoutdiktat, eksplisitt
IKKE rørt ved den allerede gode fullskjerm-registreringsveiviseren
-- kun oversiktsgridet). Grundig kodeutforskning FØR prompten ble
skrevet bekreftet at `ScoringWizard` (fullskjerm-veiviser) allerede
matcher spec §2 og ikke skulle røres, og at `ScorecardGrid` (§1) sin
hver-celle-er-en-knapp-logikk allerede fungerte -- kun tomme cellers
manglende visuelle "trykk her"-signal var det faktiske problemet i
runde 1.
**V0-eksport "zip 27" mottatt og portet, LIVE (2026-08-03):**
`components/score-overview.tsx` fra zip-en var en velfungerende,
selvstendig mock-datavisning (hull-fokuserte spillerkort, dashet
"+"-affordance for uregistrerte hull, sammenleggbart fullt
scorekort, hurtig-hopp-hullstripe) som eksplisitt respekterte
omfanget -- V0 gjenkjente selv at registreringsvinduet ikke skulle
røres og mocket kun et representativt stedfortreder-vindu for det.
Portet MANUELT mot ekte data i stedet for å ta filen direkte (samme
rutine som alltid): ny `PlayerHoleCards`-komponent i
round-detail.tsx gjenbruker ekte `Player`/`ApiHole`/`ApiFormatResult`
-typene, ekte `goPrev`/`goNext` (wrap-around, ikke V0s klampede
prev/next), ekte `sumForPlayer`/`stablefordPoints`/`golfTermForScore`
-hjelpere, og EKSAKT samme match-fargelegging/ikke-tellende-partner-
logikk som `ScorecardGrid` allerede brukte (`formatResult.holes[].
entries[].counted`) -- ingen egen reimplementert kopi av denne
logikken. `ScorecardGrid` selv er UENDRET, bare flyttet til en ny
"Vis hele scorekortet"-bryter (default lukket) i stedet for å være
standardvisningen -- ingen informasjon fjernet, kun omprioritert.
`ScorecardCell` fikk en ny valgfri `size`-prop ("sm"/"lg") for å la
det store spillerkortet gjenbruke SAMME form-/fargekomponent som det
kompakte gridet, i stedet for en divergerende kopi.
**Én reell avviksfeil funnet og rettet FØR utrulling:** V0s
hurtig-hopp-hullstripe brukte `size-9` (36px) -- under CLAUDE.md sitt
ufravikelige 44px-trykkgulv. Rettet til `size-11` FØR
browserverifisering, ikke bare typesjekk.
**Scratch-/browserverifisert grundig** (isolert DB+MinIO+API+
frontend-container, samme mønster som alltid) på en ekte 390px mobil
viewport: singel-runde (registrerte hull vs. dashet "+"-affordance
på uregistrert hull, netto/Stableford-poeng, Ut/Inn/Sum),
fourball-runde (lag-farget venstrekant, "Vant hullet"-merke lest
direkte fra ekte `formatResult`, ikke tie-brutt reimplementering),
ekte trykk-gjennom til den URØRTE `ScoringWizard`-en bekreftet (skår
registrert, veiviseren avanserte automatisk til neste spiller, kortet
bak oppdaterte seg live), og "Vis hele scorekortet" bekreftet å vise
det uendrede gamle gridet korrekt. "Spillere og runde"-fanen
(urørt) verifisert uendret.
**Rullet ut live 2026-08-03**, ren frontend-endring (ingen
migrasjon), bruker bekreftet eksplisitt før utrulling. Zip-en slettet
etter merge, per etablert rutine.
**Bevisst utenfor omfang, ikke bygget:** delt-ball-formater
(foursome/greensome/scramble/chapman -- `SideScorecardGrid`) har
ingen tilsvarende kortvisning ennå -- V0-mockupen dekket kun
spiller-rad-tilfellet (`ScorecardGrid`), og det fantes ingen
tilsvarende referanse å bygge en "side-kort"-variant mot. Fortsatt
det brede gridet for de formatene, uendret.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet

View file

@ -1883,8 +1883,18 @@ class RoundUpdate(BaseModel):
holes_planned: Literal[9, 18] | None = None
start_hole: int | None = Field(default=None, ge=1, le=18)
# Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial-
# matematikken selv, kan derfor endres uansett fullført-status.
play_format: Literal["stroke", "match", "stableford"] | None = None
# matematikken selv, kan derfor endres uansett fullført-status. Utvidet
# 2026-08-03 til samme fulle sett som RoundCreate.play_format (var
# blitt hengende igjen på kun de tre opprinnelige formatene fra FØR de
# åtte nye formatene -- ADR-039 -- ble bygget, en ren forglemmelse, ikke
# en bevisst begrensning). Et format som krever ekstra oppsett (sider/
# best_n/lineup_order) som ikke finnes ennå håndteres allerede av
# eksisterende "sett opp X først"-meldinger andre steder, ikke noe nytt
# å validere her.
play_format: Literal[
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high",
] | None = None
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på

View file

@ -173,6 +173,26 @@ function apiStatLevel(s: StatLevel): "strokes_only" | "strokes_and_putts" | "ful
function apiGenderOf(g: Gender): "m" | "f" | "x" {
return g === "mann" ? "m" : g === "kvinne" ? "f" : "x"
}
function uiGenderOf(g: ApiGender): Gender {
return g === "m" ? "mann" : "kvinne"
}
// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
// round-detail.tsx sin splitName() -- brukt til å forhåndsutfylle
// gjeste-navnefeltene fra søket når "Legg til uten konto (gjest)" trykkes.
function splitName(full: string): { first: string; last: string } {
const trimmed = full.trim()
const spaceIdx = trimmed.indexOf(" ")
if (spaceIdx === -1) return { first: trimmed, last: "" }
return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() }
}
type KnownGuest = {
guest_first_name: string
guest_last_name: string | null
gender: ApiGender | null
handicap_index: number | null
}
const STAT_LEVELS: { id: StatLevel; label: string; desc: string }[] = [
{ id: "kun-slag", label: "Kun slag", desc: "Raskest — bare det som trengs for resultat og HCP" },
@ -2061,6 +2081,53 @@ function Step3({
)
}
// Egen komponent for fornavn/etternavn-redigering av en allerede lagt-til
// gjest -- lokal state (splittet fra player.name ved mount) fordi feltene
// må kunne redigeres uavhengig, men det lagrede player.name forblir det
// sammensatte "Fornavn Etternavn"-strenget resten av veiviseren bruker.
function GuestNameFields({ player, onChange }: { player: Player; onChange: (patch: Partial<Player>) => void }) {
const initial = useMemo(() => splitName(player.name), [player.id])
const [firstName, setFirstName] = useState(initial.first)
const [lastName, setLastName] = useState(initial.last)
function commit(first: string, last: string) {
onChange({ name: `${first.trim()} ${last.trim()}`.trim() })
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`first-name-${player.id}`} className="text-base font-semibold">
Fornavn
</Label>
<Input
id={`first-name-${player.id}`}
value={firstName}
onChange={(e) => {
setFirstName(e.target.value)
commit(e.target.value, lastName)
}}
className="h-12 rounded-xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`last-name-${player.id}`} className="text-base font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`last-name-${player.id}`}
value={lastName}
onChange={(e) => {
setLastName(e.target.value)
commit(firstName, e.target.value)
}}
className="h-12 rounded-xl text-base"
/>
</div>
</div>
)
}
function PlayerCard({
player,
course,
@ -2130,12 +2197,7 @@ function PlayerCard({
<div className="flex flex-col gap-5 border-t border-border p-4">
{player.isGuest && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`name-${player.id}`} className="text-base font-semibold">
Navn
</Label>
<Input id={`name-${player.id}`} value={player.name} onChange={(e) => onChange({ name: e.target.value })} className="h-12 rounded-xl text-base" />
</div>
<GuestNameFields player={player} onChange={onChange} />
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kjønn</Label>
<SegChoice<Gender>
@ -2229,10 +2291,52 @@ function AddPlayerPanel({
const [results, setResults] = useState<PersonMatch[]>([])
const [searching, setSearching] = useState(false)
const [gName, setGName] = useState("")
const [gFirstName, setGFirstName] = useState("")
const [gLastName, setGLastName] = useState("")
const [gEmail, setGEmail] = useState("")
const [gGender, setGGender] = useState<Gender>("mann")
const [gHcp, setGHcp] = useState("")
const [gStat, setGStat] = useState<StatLevel>(roundStatLevel)
const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(null)
const [knownGuestDismissed, setKnownGuestDismissed] = useState(false)
// "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere
// registrerte gjester (håndhevet server-side, se find_known_guest i
// app/routers/rounds.py -- aldri et globalt oppslag). Samme mønster som
// round-detail.tsx sin AddGuestForm.
useEffect(() => {
const trimmed = gEmail.trim()
setKnownGuestDismissed(false)
if (trimmed.length < 5 || !trimmed.includes("@")) {
setKnownGuest(null)
return
}
const handle = setTimeout(() => {
fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: KnownGuest | null) => setKnownGuest(data))
.catch(() => setKnownGuest(null))
}, 350)
return () => clearTimeout(handle)
}, [gEmail])
function useKnownGuest() {
if (!knownGuest) return
setGFirstName(knownGuest.guest_first_name)
setGLastName(knownGuest.guest_last_name ?? "")
if (knownGuest.gender) setGGender(uiGenderOf(knownGuest.gender))
if (knownGuest.handicap_index !== null) setGHcp(String(knownGuest.handicap_index).replace(".", ","))
setKnownGuestDismissed(true)
}
// Navnet fra søkefeltet skal følge med når brukeren i stedet velger
// "Legg til uten konto (gjest)" fordi personen ikke fantes.
function switchToGuestMode() {
const split = splitName(query)
setGFirstName(split.first)
setGLastName(split.last)
setMode("guest")
}
useEffect(() => {
const trimmed = query.trim()
@ -2311,17 +2415,63 @@ function AddPlayerPanel({
!searching && query.trim().length >= 2 && <p className="text-base text-muted-foreground">Fant ingen med det navnet.</p>
)}
<button type="button" onClick={() => setMode("guest")} className="min-h-[44px] text-left text-base font-bold text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<button type="button" onClick={switchToGuestMode} className="min-h-[44px] text-left text-base font-bold text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Legg til uten konto (gjest)
</button>
</>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-first-name" className="text-base font-semibold">
Fornavn
</Label>
<Input
id="guest-first-name"
autoFocus
value={gFirstName}
onChange={(e) => setGFirstName(e.target.value)}
placeholder="Fornavn"
className="h-14 rounded-2xl text-lg"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-last-name" className="text-base font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-last-name"
value={gLastName}
onChange={(e) => setGLastName(e.target.value)}
placeholder="Etternavn"
className="h-14 rounded-2xl text-lg"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn
<Label htmlFor="guest-email" className="text-base font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input id="guest-name" value={gName} onChange={(e) => setGName(e.target.value)} placeholder="Navn på gjesten" className="h-14 rounded-2xl text-lg" />
<Input
id="guest-email"
type="email"
value={gEmail}
onChange={(e) => setGEmail(e.target.value)}
placeholder="For scorekort og statistikk etter runden"
className="h-14 rounded-2xl text-lg"
/>
{knownGuest && !knownGuestDismissed && (
<div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-foreground">
Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
fra en tidligere runde du satte opp.
</p>
<Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
Bruk disse opplysningene
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-2">
@ -2363,9 +2513,18 @@ function AddPlayerPanel({
<Button
type="button"
size="lg"
disabled={gName.trim() === ""}
disabled={gFirstName.trim() === ""}
onClick={() =>
onAdd({ id: newId("guest"), name: gName.trim(), isGuest: true, gender: gGender, teeId: "", hcp: gHcp, statLevel: gStat })
onAdd({
id: newId("guest"),
name: `${gFirstName.trim()} ${gLastName.trim()}`.trim(),
isGuest: true,
gender: gGender,
email: gEmail.trim() || undefined,
teeId: "",
hcp: gHcp,
statLevel: gStat,
})
}
className="h-14 flex-1 rounded-2xl text-base font-bold"
>

View file

@ -20,6 +20,7 @@ import {
ArrowUp,
Check,
ChevronDown,
ChevronsUpDown,
Clock,
Trash2,
ChevronLeft,
@ -456,6 +457,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
const [wizardSideId, setWizardSideId] = useState<string | null>(null)
const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
// Score-fanens hull-fokuserte kortvisning (2026-08-03, V0-eksport zip 27,
// etterspurt av bruker fordi det brede gridet tok for stor plass i bredden
// på mobil -- se teecup-scorekort-og-entry-spec.md §1 for gridet dette
// supplerer). Gridet i seg selv er UENDRET og fortsatt tilgjengelig, bare
// ikke lenger standardvisningen -- default lukket ("ingenting mistes, kun
// ett ekstra trykk unna").
const [showFullScorecard, setShowFullScorecard] = useState(false)
// --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------
// Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx)
@ -1427,19 +1435,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)
) : (
<>
{/* Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, bygget
2026-07-27 -- erstatter den forrige rundens ett-hull-om-gangen-
liste, bruker eksplisitt bekreftet "JA" at grid-redesignet er
en egen, større runde). Spillere som rader (sticky venstre
navnekolonne), hull som horisontalt scrollbare kolonner --
HELE runden synlig samtidig, ikke bare det aktive hullet. */}
{/* Hull-fokusert kortvisning (2026-08-03, erstatter det brede
gridet som STANDARDVISNING -- brukeren rapporterte at gridet
(§1 i teecup-scorekort-og-entry-spec.md, bygget 2026-07-27)
tok for stor plass i bredden mobil. Se PlayerHoleCards for
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
"Vis hele scorekortet"-bryter under -- ingen informasjon er
fjernet. */}
{holes.length === 0 ? (
<div className="mt-5 flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
) : (
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
<ScorecardGrid
<PlayerHoleCards
players={players}
holesByParticipant={holesByParticipant}
holeOrder={holeOrder}
@ -1453,6 +1462,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
setCurrentHole(n)
scrollToHolePanel()
}}
onPrevHole={goPrev}
onNextHole={goNext}
onOpenEntry={(playerId, holeNumber) => {
setCurrentHole(holeNumber)
setActivePlayerId(playerId)
@ -1460,19 +1471,40 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}}
/>
{/* Rask sekvensiell hull-navigasjon -- gridet viser allerede
alle hull som kolonner (tapp en kolonneoverskrift for å
velge), men disse to gir en rask vei videre uten å måtte
scrolle bredt for hvert eneste hull. */}
<div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige hull
</Button>
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
Neste hull
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setShowFullScorecard((v) => !v)}
aria-expanded={showFullScorecard}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Vis hele scorekortet
<ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</button>
{showFullScorecard && (
<div className="border-t border-border p-3">
<ScorecardGrid
players={players}
holesByParticipant={holesByParticipant}
holeOrder={holeOrder}
holesPlanned={round.holes_planned}
currentHole={activeHole}
readOnly={readOnly}
sides={round.sides}
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult}
onSelectHole={(n) => {
setCurrentHole(n)
scrollToHolePanel()
}}
onOpenEntry={(playerId, holeNumber) => {
setCurrentHole(holeNumber)
setActivePlayerId(playerId)
setWizardPlayerId(playerId)
}}
/>
</div>
)}
</div>
</section>
)}
@ -3826,6 +3858,28 @@ function fromDatetimeLocalValue(value: string): string | null {
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
// Samme fulle format-liste/etiketter som new-round.tsx sin opprettelses-
// veiviser (FORMATS) -- API-verdiene her er RoundUpdate.play_format sine
// egne (samme streng-sett som RoundCreate), ikke wizardens lokale FormatId.
const EDITABLE_PLAY_FORMATS: { value: string; label: string }[] = [
{ value: "stroke", label: "Slagspill" },
{ value: "stableford", label: "Stableford" },
{ value: "match", label: "Match" },
{ value: "skins", label: "Skins" },
{ value: "fourball", label: "Fourball" },
{ value: "foursome", label: "Foursome" },
{ value: "greensome", label: "Greensome" },
{ value: "scramble_2", label: "Scramble (2)" },
{ value: "scramble_4", label: "Scramble (4)" },
{ value: "chapman", label: "Chapman" },
{ value: "copenhagen", label: "Københavner" },
{ value: "bbb", label: "Bingo Bango Bongo" },
{ value: "flag", label: "Flaggturnering" },
{ value: "shamble", label: "Shamble" },
{ value: "money_ball", label: "Money Ball" },
{ value: "high_low_high", label: "High-low-high" },
]
function EditRoundPanel({
round,
onPatch,
@ -3841,12 +3895,14 @@ function EditRoundPanel({
const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
// ADR-038, utvidet med Stableford (migrasjon 038) -- ren metadata, aldri
// sperret av fullført-status (rører aldri AGS/differensial-matematikken
// selv).
const currentEditablePlayFormat: "stroke" | "match" | "stableford" =
round.play_format === "match" ? "match" : round.play_format === "stableford" ? "stableford" : "stroke"
const [playFormat, setPlayFormat] = useState<"stroke" | "match" | "stableford">(currentEditablePlayFormat)
// ADR-038, utvidet med Stableford (migrasjon 038), utvidet igjen
// 2026-08-03 til ALLE format (samme sett som new-round.tsx sin
// opprettelses-veiviser -- var kun blitt hengende igjen på de tre
// opprinnelige verdiene her, en ren forglemmelse fra ADR-039-rundene,
// ikke en bevisst begrensning) -- ren metadata, aldri sperret av
// fullført-status (rører aldri AGS/differensial-matematikken selv).
const currentEditablePlayFormat = round.play_format
const [playFormat, setPlayFormat] = useState<string>(currentEditablePlayFormat)
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
// av fullført-status (samme begrunnelse som playFormat over).
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
@ -3920,21 +3976,20 @@ function EditRoundPanel({
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Spilleform</span>
<div className="flex gap-2">
{(
[
{ value: "stroke" as const, label: "Slagspill" },
{ value: "stableford" as const, label: "Stableford" },
{ value: "match" as const, label: "Matchspill" },
]
).map((opt) => (
{/* Bytte til et to-sidet/delt-ball-format som ennå ikke har sider
opprettet, eller et format som trenger egen oppsett (Shamble sin
best_n, Money Ball sin lineup_order), vises allerede av
eksisterende "sett opp X først"-meldinger andre steder i
"Spillere og runde" -- ingen egen validering trengs her. */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{EDITABLE_PLAY_FORMATS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setPlayFormat(opt.value)}
aria-pressed={playFormat === opt.value}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
"min-h-11 rounded-xl border px-2 py-2 text-sm font-bold leading-tight transition-colors",
playFormat === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
@ -4648,6 +4703,298 @@ function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
return 2
}
function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
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!.played && h!.score !== null)
if (played.length === 0) return null
return {
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
par: played.reduce((sum, h) => sum + h.par, 0),
}
}
// Hull-fokusert kortvisning for Score-fanen (2026-08-03, V0-eksport zip 27
// portert manuelt mot ekte data -- se teecup-scorekort-og-entry-spec.md §1
// og brukerens eksplisitte "gi V0 friere spillerom"-tilbakemelding). Viser
// KUN aktivt hull, spillere som stablede kort i stedet for et bredt grid --
// løser at ScorecardGrid tar for stor plass i bredden på mobil. INGEN
// informasjon fjernes: alt som var synlig i gridet finnes fortsatt her
// (HCP/Ut/Inn/Sum/netto/mottatte slag/vunnet hull/ikke-tellende partner),
// resten (alle 18 hull samtidig) er ScorecardGrid selv, uendret, bak en
// "Vis hele scorekortet"-bryter (se render-stedet i hoved-komponenten).
// Trykk åpner SAMME eksisterende fullskjerm-veiviser som før (onOpenEntry)
// -- selve registreringsvinduet er bevisst IKKE rørt av denne rundens
// V0-prompt.
function PlayerHoleCards({
players,
holesByParticipant,
holeOrder,
holesPlanned,
currentHole,
readOnly,
sides,
isTwoSided,
formatResult,
onSelectHole,
onPrevHole,
onNextHole,
onOpenEntry,
}: {
players: Player[]
holesByParticipant: Record<string, ApiHole[]>
holeOrder: number[]
holesPlanned: number
currentHole: number
readOnly: boolean
sides: ApiSide[]
isTwoSided: boolean
formatResult: ApiFormatResult | null
onSelectHole: (holeNumber: number) => void
onPrevHole: () => void
onNextHole: () => void
onOpenEntry: (playerId: string, holeNumber: number) => void
}) {
const holeMeta: Record<number, { par: number; index: 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, index: h.stroke_index }
}
}
const meta = holeMeta[currentHole]
const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
const formatHole = showMatchColors ? formatHolesByNumber.get(currentHole) : undefined
function sideOf(playerId: string): "a" | "b" | null {
const p = players.find((x) => x.id === playerId)
if (!p?.roundSideId) return null
return p.roundSideId === sides[0]?.id ? "a" : "b"
}
const sortedPlayers =
sides.length === 2
? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
: players
const allRegisteredByHole: Record<number, boolean> = {}
for (const n of holeOrder) {
allRegisteredByHole[n] = players.every((p) => {
const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
})
}
return (
<div className="flex flex-col gap-4">
{/* Hull-navigator: gjenbruker samme goPrev/goNext-logikk (wrap-around)
som resten av siden allerede har, pluss en hurtig-hopp-stripe --
erstatter behovet for å scrolle et bredt grid for å bytte hull. */}
<div className="rounded-2xl border border-border bg-card p-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={onPrevHole}
aria-label="Forrige hull"
className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige
</button>
<div className="flex min-w-[7.5rem] flex-col items-center px-1 text-center">
<span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
Hull {currentHole}
</span>
<span className="mt-1 text-xs font-semibold text-muted-foreground tabular-nums">
{meta ? `Par ${meta.par} · Indeks ${meta.index}` : " "}
</span>
</div>
<button
type="button"
onClick={onNextHole}
aria-label="Neste hull"
className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Neste
<ChevronRight aria-hidden="true" className="size-5" />
</button>
</div>
<nav aria-label="Hopp til hull" className="-mx-1 mt-3 overflow-x-auto px-1">
<ul className="flex gap-1.5 pb-0.5">
{holeOrder.map((n) => {
const active = n === currentHole
const registered = allRegisteredByHole[n]
return (
<li key={n}>
<button
type="button"
onClick={() => onSelectHole(n)}
aria-current={active ? "true" : undefined}
aria-label={`Hull ${n}${registered ? ", registrert" : ""}`}
className={cn(
// size-11 (44px), ikke V0-eksportens size-9 -- CLAUDE.md sitt
// ufravikelige 44px-trykkgulv gjelder ALT trykkbart, også en
// kompakt hurtig-hopp-stripe.
"flex size-11 shrink-0 items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: registered
? "border-primary/40 bg-primary/10 text-foreground"
: "border-border bg-background text-muted-foreground hover:bg-accent/50",
)}
>
{n}
</button>
</li>
)
})}
</ul>
</nav>
</div>
{isTwoSided && sides.length === 2 && (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
<span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-primary" />
<span className="font-bold text-foreground">{sides[0]?.label?.trim() || "Side A"}</span>
<span className="mx-1 text-muted-foreground">mot</span>
<span className="font-bold text-foreground">{sides[1]?.label?.trim() || "Side B"}</span>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-brand-orange" />
</div>
)}
<ul className="flex flex-col gap-3">
{sortedPlayers.map((player) => {
const list = holesByParticipant[player.id]
const apiHole = list?.find((h) => h.hole_number === currentHole) ?? null
const hasScore = Boolean(apiHole?.played) && (apiHole?.score !== null || apiHole?.picked_up)
const diff = hasScore && meta && apiHole?.score !== null ? (apiHole!.score as number) - meta.par : null
const net =
hasScore && apiHole?.score !== null && apiHole?.strokes_received != null
? (apiHole!.score as number) - apiHole!.strokes_received
: null
const points = net !== null && meta ? stablefordPoints(net, meta.par) : null
const playerSide = showMatchColors ? sideOf(player.id) : null
const entry = formatHole?.entries.find((e) => e.id === player.id)
const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
const dimmed = Boolean(entry && entry.counted === false)
const frontSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n <= 9)) : null
const backSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n >= 10)) : null
const totalSum = sumForPlayer(list, holeOrder)
return (
<li key={player.id}>
<div
className={cn(
"overflow-hidden rounded-3xl border bg-card shadow-sm shadow-black/5 transition-colors",
dimmed ? "border-dashed border-border opacity-60" : "border-border",
)}
style={
playerSide
? { borderLeftWidth: 6, borderLeftColor: playerSide === "a" ? "var(--primary)" : "var(--brand-orange)" }
: undefined
}
>
<button
type="button"
disabled={readOnly}
onClick={() => onOpenEntry(player.id, currentHole)}
aria-label={
hasScore
? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
: `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
}
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{playerSide && (
<span
aria-hidden="true"
className={cn("size-2.5 shrink-0 rounded-full", playerSide === "a" ? "bg-primary" : "bg-brand-orange")}
/>
)}
<span className="truncate text-base font-bold tracking-tight text-foreground">{player.name}</span>
{player.isSelf && (
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
Deg
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs font-medium text-muted-foreground tabular-nums">
<span>HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : ""}</span>
{holesPlanned === 18 ? (
<>
<span aria-hidden="true">·</span>
<span>
Ut {frontSum ? frontSum.strokes : ""} · Inn {backSum ? backSum.strokes : ""} · Sum{" "}
<span className="font-bold text-foreground">{totalSum ? totalSum.strokes : ""}</span>
{totalSum ? ` (${formatToPar(totalSum.strokes - totalSum.par)})` : ""}
</span>
</>
) : (
totalSum && (
<>
<span aria-hidden="true">·</span>
<span>
Sum <span className="font-bold text-foreground">{totalSum.strokes}</span> (
{formatToPar(totalSum.strokes - totalSum.par)})
</span>
</>
)
)}
</div>
{dimmed && (
<span className="text-[11px] font-semibold text-muted-foreground">Teller ikke dette hullet</span>
)}
{won && !dimmed && (
<span
className="mt-0.5 inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
style={{ backgroundColor: playerSide === "b" ? "var(--brand-orange)" : "var(--primary)" }}
>
Vant hullet
</span>
)}
</div>
<div className="flex shrink-0 flex-col items-center gap-1">
{hasScore ? (
<>
<ScorecardCell
value={apiHole?.picked_up ? null : (apiHole!.score as number)}
diff={diff}
pickedUp={apiHole?.picked_up}
size="lg"
/>
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
{net !== null ? `Netto ${net}` : " "}
{points !== null ? ` · ${points} p` : ""}
</span>
</>
) : (
<>
<span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
<Plus aria-hidden="true" className="size-5" />
</span>
{apiHole?.strokes_received ? (
<StrokeDots count={apiHole.strokes_received} />
) : (
<span className="text-[11px] font-semibold text-primary">Registrer</span>
)}
</>
)}
</div>
</button>
</div>
</li>
)
})}
</ul>
</div>
)
}
function ScorecardGrid({
players,
holesByParticipant,
@ -5003,18 +5350,31 @@ function ScorecardCell({
value,
diff,
pickedUp,
size = "sm",
}: {
value: number | null
diff: number | null
pickedUp?: boolean
// "lg" brukt av PlayerHoleCards (2026-08-03) -- samme form-/fargespråk,
// bare skalert opp for den hull-fokuserte kortvisningen.
size?: "sm" | "lg"
}) {
const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
const shared = cn(
"flex items-center justify-center font-extrabold tabular-nums",
size === "lg" ? "size-12 text-2xl" : "size-7 text-sm",
)
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
// vises som "PU" i stedet for det (allerede kjente) Net Double Bogey-
// slagtallet, som ellers ville sett ut som en vilkårlig høy score.
if (pickedUp) {
return (
<span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange")}>
<span
className={cn(
shared,
"rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
size === "lg" ? "text-sm" : "text-[10px]",
)}
>
PU
</span>
)

File diff suppressed because one or more lines are too long