diff --git a/074_player_extended_fields.sql b/074_player_extended_fields.sql new file mode 100644 index 0000000..e16b7de --- /dev/null +++ b/074_player_extended_fields.sql @@ -0,0 +1,38 @@ +-- ===================================================================== +-- TeeCup — Utvidede spillerfelt i org-spillerpoolen (2026-08-14). +-- Bruker: midlertidige/nyopprettede spillere i "Deltakere"-flyten +-- (org-individuelle turneringer, individual-tournament-detail.tsx) +-- manglet felt en reell klubbadministrator trenger ved registrering. +-- +-- Splitter navn i for-/etternavn -- SAMME mønster som migrasjon 052 +-- (round_participant.guest_first_name/guest_last_name): display_name +-- beholdes UENDRET som det faktisk VISTE fulle navnet (unngår å røre +-- alle eksisterende steder som leser player.display_name direkte, +-- bl.a. leaderboards/matcher/rostre) -- first_name/last_name er en NY, +-- valgfri kilde til sannhet ved REDIGERING i det utvidede skjemaet, +-- ikke en erstatning. App-laget (ikke en DB-trigger) holder dem i sync +-- ved oppretting, samme "kalleren regner ut display_name"-ansvar som +-- round_participant sin ekvivalent. +-- +-- `paid`/`comment`: registreringsadministrasjon (betalt-avkrysning, +-- fritekstnotat) -- fantes ikke i noen form før. +-- ===================================================================== +\set ON_ERROR_STOP on + +ALTER TABLE player ADD COLUMN first_name text; +ALTER TABLE player ADD COLUMN last_name text; +ALTER TABLE player ADD COLUMN paid boolean NOT NULL DEFAULT false; +ALTER TABLE player ADD COLUMN comment text; + +-- Backfill for EKSISTERENDE spillere -- samme "første ord = fornavn, +-- resten = etternavn"-heuristikk som migrasjon 052 sin backfill. +UPDATE player +SET first_name = CASE + WHEN position(' ' IN display_name) > 0 THEN split_part(display_name, ' ', 1) + ELSE display_name + END, + last_name = CASE + WHEN position(' ' IN display_name) > 0 + THEN NULLIF(trim(substring(display_name FROM position(' ' IN display_name) + 1)), '') + ELSE NULL + END; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 56d0b4f..f4e1b16 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6912,6 +6912,96 @@ containere startet rent. --- +## ADR-070: Utvidet spillerskjema i "Deltakere" -- for-/etternavn, betalt, kommentar + bla i eksisterende (2026-08-14) + +Foranledning: bruker viste to skjermbilder av "Deltakere"-flyten i en +org-individuell turnering (`individual-tournament-detail.tsx`). Søkefeltet +tilbød KUN "Opprett ny spiller: «Erik»" -- ingen måte å velge en allerede +eksisterende spiller i org-poolen. Brukeren ba samtidig om at midlertidige/ +nyopprettede spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato, +E-postadresse, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp, +Sjekkboks for betalt, Kommentar. + +**Rotårsak for "kan ikke velge eksisterende": ikke en datalastings-bug.** +`AddParticipantControl` sin `matches`-liste ble KUN utledet når søkefeltet +hadde en ikke-tom, substring-matchende query -- det fantes ingen måte å +bla i poolen uten allerede å kjenne et treffende navn. Fikset ved å legge +til et `available`-memo (`pool` minus allerede lagt-til deltakere) som +`matches` faller tilbake til når søket er tomt, i stedet for å returnere +`[]`. `MAX_VISIBLE_MATCHES` (8, opp fra hardkodet 6) begrenser visningen. + +**7 av 11 ønskede felt fantes allerede i `player`-skjemaet** (migrasjon +007: `birth_date`, `email`, `club_member_number`, `club`, `country`, +`handicap_index`, `gender`) -- bare ikke eksponert i DENNE UI-flyten. +`first_name`, `last_name`, `paid`, `comment` manglet reelt -- migrasjon +074 legger dem til på `player`, med samme for-/etternavn-splitt-mønster +som migrasjon 052 (`round_participant.guest_first_name/guest_last_name`): +`display_name` beholdes UENDRET som det faktisk viste navnet alle andre +steder i appen leser (leaderboards, matcher, rostring) -- `first_name`/ +`last_name` er en NY, valgfri kilde ved siden av, ikke en erstatning. +Backfill for eksisterende spillere bruker samme "første ord = fornavn, +resten = etternavn"-heuristikk som migrasjon 052 sin backfill. + +**Bevisst avvik fra det etablerte "backend synker display_name"-mønsteret:** +migrasjon 052/015 lot backend beregne/synke et avledet visningsnavn fra +for-/etternavn ved skriving. Her lot jeg i stedet FRONTEND beregne +`display_name = \`${firstName} ${lastName}\`.trim()` før sending, som et +helt normalt felt sammen med de nye `first_name`/`last_name`-feltene -- +for å unngå å røre `create_player`/`update_player` sin eksisterende +kontrakt (generisk, whitelist-drevet PATCH via Pydantic sin +`exclude_unset`, brukt av mengder av eksisterende kallere). `paid`/ +`comment` er ren registreringsadministrasjon, ingen kobling til noe +eksisterende. + +**Frontend:** `AddParticipantControl` fikk et progressivt avslørt +skjema -- Fornavn/Etternavn/Kjønn/Hcp alltid synlig, resten bak en +"+ Flere detaljer"-knapp (Fødselsdato, E-post, Hjemmeklubb, +Medlemsnummer, Land, Betalt-avkrysning, Kommentar). `splitName()`- +hjelpefunksjon forhåndsutfyller Fornavn/Etternavn fra søkefeltets +frittekst når "Opprett ny spiller"-knappen trykkes (samme heuristikk som +backfillen). + +**Fant og fikset underveis (selvfunnet under nettleserverifisering, ikke +brukerrapportert):** tom-tilstand-teksten når `available.length === 0` +sa "Ingen eksisterende spillere i organisasjonen ennå" uansett årsak -- +misvisende når poolen faktisk HAR spillere, de er bare allerede lagt til +denne turneringen. Splittet i to riktige meldinger (`pool.length === 0` +vs. alle allerede lagt til), pluss en tredje ny melding for "søkte, null +substring-treff blant eksisterende" (viste tidligere ingenting). + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert). +`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch +`teecup_api`-container (live-mountet kode) + lokal `next dev`: opprettet +en eksisterende spiller i poolen ("Kari Nordmann") og bekreftet den nå +dukker opp direkte ved åpning av "Legg til deltaker" med tomt søkefelt +(fikset bug), opprettet en ny spiller med alle 11 felt utfylt via det +utvidede skjemaet og bekreftet samtlige lagret korrekt i databasen. +Lys+mørk bekreftet, inkl. det utvidede skjemaet med avkrysningsboks. + +**Verifiseringsfallgruve, ikke en app-bug:** Chrome DevTools MCP sin +`fill`-verktøy satte visuelt riktig verdi på et natively multi-segment +``, men trigget ikke Reacts `onChange` pålitelig -- +`birth_date` kom tilbake `null` fra API-et til tross for at feltet viste +riktig dato rett før innsending. Bekreftet ved å gjenta med +tastatur-drevet `press_key` inn i dato-feltets spinbuttons i stedet -- +lagret korrekt. Ingen kodeendring nødvendig; kun automatiserings- +verktøyets begrensning med native dato-inputs, ikke en reell +applikasjonsbug. + +**Bevisst UTENFOR omfang:** den strukturelt identiske "bla i eksisterende +spillere"-begrensningen finnes trolig også i lag-turneringers roster- +tillegg (`tournament-detail.tsx`, samme `pool`/`matches`/`exactMatch`- +mønster) -- IKKE rørt i denne runden, brukerens skjermbilder gjaldt +spesifikt den org-individuelle skjermen. Kjent, ikke glemt. + +**Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db` +som `teeoff_admin` (4 eksisterende spillere backfillet), etter +eksplisitt bekreftelse fra bruker. `docker compose build teecup_api +teecup_frontend && up -d` -- begge containere startet rent. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666d59a..0f225d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11282,3 +11282,66 @@ Neste steg: som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker. `docker compose build teecup_api teecup_frontend && up -d` -- begge containere startet rent. + +86. **Utvidet spillerskjema i "Deltakere" (ADR-070) — 2026-08-14.** + Bruker viste skjermbilde av "Deltakere"-flyten i en org-individuell + turnering: søkefeltet tilbød KUN "Opprett ny spiller: «X»", ingen + måte å velge en eksisterende spiller i org-poolen. Ba samtidig om at + nye spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato, + E-post, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp, Betalt, + Kommentar. + + Rotårsak for "kan ikke velge eksisterende": `AddParticipantControl` + sin `matches`-liste ble kun utledet ved ikke-tomt søk -- ingen måte å + bla i poolen uten å kjenne et treffende navn fra før. Fikset med et + `available`-memo (pool minus allerede lagt til) som `matches` faller + tilbake til ved tomt søk. + + Migrasjon 074: 7 av 11 ønskede felt fantes allerede på `player` + (migrasjon 007), kun `first_name`/`last_name`/`paid`/`comment` var + reelt nye. Samme for-/etternavn-splitt-mønster som migrasjon 052 + (`display_name` uendret som det faktisk viste navnet, `first_name`/ + `last_name` en ny valgfri kilde ved siden av) -- men her beregner + FRONTEND `display_name` før sending i stedet for at backend synker + det, for å unngå å røre `create_player`/`update_player` sin + eksisterende generiske PATCH-kontrakt. Backfill av eksisterende + spillere med samme heuristikk som migrasjon 052. + + Frontend: progressivt avslørt skjema i `AddParticipantControl` + ("+ Flere detaljer"), `splitName()`-hjelper forhåndsutfyller Fornavn/ + Etternavn fra søkefeltets frittekst ved "Opprett ny spiller". + + **Fant og fikset underveis (selvfunnet, ikke brukerrapportert):** + misvisende tom-tilstand-tekst når alle pool-spillere allerede var + lagt til turneringen ("ingen spillere i org ennå" -- faktisk feil, + de var bare allerede lagt til). Splittet i riktige meldinger for de + tre reelle tilstandene. + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert). + `tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch + `teecup_api`-container (live-mountet kode) + lokal `next dev`: bla-i- + eksisterende-fikset bekreftet med en forhåndsopprettet spiller + synlig direkte ved tomt søk, alle 11 felt bekreftet lagret korrekt + for en ny spiller opprettet via det utvidede skjemaet. Lys+mørk + bekreftet inkl. avkrysningsboks-styling. Scratch-stacken fullstendig + revet ned -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt. + + **Verifiseringsfallgruve, ikke app-bug:** Chrome DevTools MCP sin + `fill`-verktøy satte visuelt riktig verdi på et natively multi- + segment ``, men trigget ikke Reacts `onChange` + pålitelig -- `birth_date` lagret som `null` til tross for korrekt + visning rett før innsending. Bekreftet ved tastatur-drevet + `press_key` inn i dato-feltets spinbuttons i stedet -- lagret + korrekt. Kun et automatiseringsverktøy-kvirk med native dato-inputs, + ingen kodeendring nødvendig. + + **Bevisst UTENFOR omfang:** samme "bla i eksisterende"-begrensning + finnes trolig også i lag-turneringers roster-tillegg + (`tournament-detail.tsx`, strukturelt identisk mønster) -- ikke rørt, + brukerens skjermbilder gjaldt spesifikt den org-individuelle skjermen. + + **Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db` + som `teeoff_admin` (4 eksisterende spillere backfillet), etter + eksplisitt bekreftelse fra bruker. `docker compose build teecup_api + teecup_frontend && up -d` -- begge containere startet rent. diff --git a/app/routers/players.py b/app/routers/players.py index 5f440e1..2294775 100644 --- a/app/routers/players.py +++ b/app/routers/players.py @@ -34,6 +34,14 @@ class PlayerCreate(BaseModel): country: str | None = Field(default=None, max_length=100) club: str | None = Field(default=None, max_length=200) club_member_number: str | None = Field(default=None, max_length=50) + # Utvidet registreringsskjema (2026-08-14, migrasjon 074) -- for-/ + # etternavn er en VALGFRI, separat kilde ved siden av display_name + # (som fortsatt er det som faktisk vises overalt ellers, uendret) -- + # se migrasjonens header. paid/comment er ren registreringsadministrasjon. + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + paid: bool = False + comment: str | None = Field(default=None, max_length=2000) class Player(BaseModel): @@ -48,11 +56,16 @@ class Player(BaseModel): country: str | None club: str | None club_member_number: str | None + first_name: str | None + last_name: str | None + paid: bool + comment: str | None _PLAYER_COLUMNS = """ id::text, display_name, handicap_index::float AS handicap_index, gender, - mobile, email, birth_date, nickname, country, club, club_member_number + mobile, email, birth_date, nickname, country, club, club_member_number, + first_name, last_name, paid, comment """ @@ -77,8 +90,9 @@ async def create_player( f""" INSERT INTO player (organization_id, display_name, handicap_index, gender, - mobile, email, birth_date, nickname, country, club, club_member_number) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + mobile, email, birth_date, nickname, country, club, club_member_number, + first_name, last_name, paid, comment) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING {_PLAYER_COLUMNS} """, organization_id, @@ -92,6 +106,10 @@ async def create_player( body.country, body.club, body.club_member_number, + body.first_name, + body.last_name, + body.paid, + body.comment, ) return Player(**dict(row)) @@ -118,6 +136,10 @@ class PlayerUpdate(BaseModel): country: str | None = Field(default=None, max_length=100) club: str | None = Field(default=None, max_length=200) club_member_number: str | None = Field(default=None, max_length=50) + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + paid: bool | None = None + comment: str | None = Field(default=None, max_length=2000) @router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index ce3b686..a07f23c 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -56,6 +56,33 @@ type ApiPlayer = { display_name: string handicap_index: number | null gender: "m" | "f" | "x" | null + // Utvidet registreringsskjema (2026-08-14, migrasjon 074). + first_name: string | null + last_name: string | null + birth_date: string | null // ISO yyyy-mm-dd + email: string | null + club_member_number: string | null + club: string | null + country: string | null + paid: boolean + comment: string | null +} + +// Feltene sendt inn ved "Opprett ny spiller" (AddParticipantControl) -- +// egen type fremfor å gjenbruke ApiPlayer direkte, siden dette er et +// SKJEMA (før spilleren finnes), ikke en allerede opprettet rad. +type NewPlayerInput = { + firstName: string + lastName: string + gender?: "m" | "f" | "x" + birthDate?: string + email?: string + clubMemberNumber?: string + club?: string + country?: string + handicapIndex?: number + paid: boolean + comment?: string } type ApiTournamentInfo = { @@ -414,16 +441,30 @@ export function IndividualTournamentDetail({ setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name))) } - async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") { + async function addNewPlayerAndParticipant(input: NewPlayerInput) { setError(null) + const displayName = `${input.firstName} ${input.lastName}`.trim() const playerRes = await fetch(`/orgs/${organizationId}/players`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }), + body: JSON.stringify({ + display_name: displayName, + first_name: input.firstName.trim() || null, + last_name: input.lastName.trim() || null, + handicap_index: input.handicapIndex ?? null, + gender: input.gender ?? null, + birth_date: input.birthDate || null, + email: input.email?.trim() || null, + club_member_number: input.clubMemberNumber?.trim() || null, + club: input.club?.trim() || null, + country: input.country?.trim() || null, + paid: input.paid, + comment: input.comment?.trim() || null, + }), }) if (!playerRes.ok) { - setError("Klarte ikke å opprette spilleren.") + setError(await errorMessage(playerRes, "Klarte ikke å opprette spilleren.")) return } const player: ApiPlayer = await playerRes.json() @@ -735,7 +776,7 @@ function SetupTab({ onUpdateBbbSweepBonus: (enabled: boolean) => void onUpdateFlagMapVisible: (visible: boolean) => void onAddParticipant: (playerId: string) => Promise - onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise + onAddNewPlayer: (input: NewPlayerInput) => Promise onRemoveParticipant: (id: string) => Promise onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise @@ -1021,7 +1062,7 @@ function ParticipantsCard({ pool: ApiPlayer[] classes: ApiTournamentClass[] onAddParticipant: (playerId: string) => Promise - onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise + onAddNewPlayer: (input: NewPlayerInput) => Promise onRemoveParticipant: (id: string) => Promise onSetClass: (participantId: string, classId: string | null) => Promise }) { @@ -1088,6 +1129,19 @@ function ParticipantsCard({ ) } +// Splitter et fritekst-navn i for-/etternavn -- samme "første ord = +// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin +// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra +// søkefeltet (fortsatt redigerbart -- kun et fornuftig utgangspunkt). +function splitName(fullName: string): { first: string; last: string } { + const trimmed = fullName.trim() + const spaceIndex = trimmed.indexOf(" ") + if (spaceIndex === -1) return { first: trimmed, last: "" } + return { first: trimmed.slice(0, spaceIndex), last: trimmed.slice(spaceIndex + 1).trim() } +} + +const MAX_VISIBLE_MATCHES = 8 + function AddParticipantControl({ pool, alreadyIn, @@ -1097,20 +1151,31 @@ function AddParticipantControl({ pool: ApiPlayer[] alreadyIn: Set onAddExisting: (playerId: string) => Promise - onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise + onAddNew: (input: NewPlayerInput) => Promise }) { const [open, setOpen] = useState(false) const [query, setQuery] = useState("") const [creating, setCreating] = useState(false) + const [firstName, setFirstName] = useState("") + const [lastName, setLastName] = useState("") const [newHandicap, setNewHandicap] = useState("") const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("") + const [showMoreDetails, setShowMoreDetails] = useState(false) + const [birthDate, setBirthDate] = useState("") + const [email, setEmail] = useState("") + const [clubMemberNumber, setClubMemberNumber] = useState("") + const [club, setClub] = useState("") + const [country, setCountry] = useState("") + const [paid, setPaid] = useState(false) + const [comment, setComment] = useState("") const trimmed = query.trim() + const available = useMemo(() => pool.filter((p) => !alreadyIn.has(p.id)), [pool, alreadyIn]) const matches = useMemo(() => { - if (!trimmed) return [] + if (!trimmed) return available const q = trimmed.toLowerCase() - return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q)) - }, [pool, trimmed, alreadyIn]) + return available.filter((p) => p.display_name.toLowerCase().includes(q)) + }, [available, trimmed]) const exactMatch = useMemo( () => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()), [pool, trimmed], @@ -1120,8 +1185,18 @@ function AddParticipantControl({ function reset() { setQuery("") setCreating(false) + setFirstName("") + setLastName("") setNewHandicap("") setNewGender("") + setShowMoreDetails(false) + setBirthDate("") + setEmail("") + setClubMemberNumber("") + setClub("") + setCountry("") + setPaid(false) + setComment("") } async function handleAddExisting(playerId: string) { @@ -1130,11 +1205,30 @@ function AddParticipantControl({ setOpen(false) } + function startCreating() { + const { first, last } = splitName(trimmed) + setFirstName(first) + setLastName(last) + setCreating(true) + } + async function handleCreate() { - if (trimmed.length < 2) return + if (firstName.trim().length === 0) return const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", ".")) const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined - await onAddNew(trimmed, hcp, newGender || undefined) + await onAddNew({ + firstName, + lastName, + gender: newGender || undefined, + handicapIndex: hcp, + birthDate: birthDate || undefined, + email: email || undefined, + clubMemberNumber: clubMemberNumber || undefined, + club: club || undefined, + country: country || undefined, + paid, + comment: comment || undefined, + }) reset() setOpen(false) } @@ -1158,7 +1252,7 @@ function AddParticipantControl({
{ setQuery(e.target.value) @@ -1187,27 +1281,46 @@ function AddParticipantControl({
- {matches.length > 0 && ( -
    - {matches.slice(0, 6).map((p) => ( -
  • - -
  • - ))} -
+ {!creating && ( + <> + {matches.length === 0 && available.length === 0 && ( +

+ {pool.length === 0 + ? "Ingen eksisterende spillere i organisasjonen ennå -- opprett den første under." + : "Alle eksisterende spillere er allerede lagt til -- opprett en ny under."} +

+ )} + {matches.length === 0 && available.length > 0 && trimmed.length > 0 && ( +

Ingen treff blant eksisterende spillere.

+ )} + {matches.length > 0 && ( +
    + {matches.slice(0, MAX_VISIBLE_MATCHES).map((p) => ( +
  • + +
  • + ))} +
+ )} + {matches.length > MAX_VISIBLE_MATCHES && ( +

+ +{matches.length - MAX_VISIBLE_MATCHES} flere -- fortsett å skrive for å begrense listen. +

+ )} + )} {showCreate && !creating && ( + ) : ( +
+
+
+ + setBirthDate(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+
+ + setEmail(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+
+ +
+
+ + setClub(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+
+ + setClubMemberNumber(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+
+ +
+ + setCountry(e.target.value)} + className="h-10 w-full rounded-xl text-sm sm:w-1/2" + /> +
+ + + +
+ +