Update Todos

Bygg DELETE .../matches/{id}/participants/{id}

Scratch-verifisere DELETE-endepunktet

Integrere session-blind-draw.tsx mot ekte API

Finn+fiks synlighetshull: org-admin så ikke egne tilføyde deltakere

Ekte typesjekket frontend-build

Fikse+verifisere 500-krasj på null handicap_index

Rulle ut live

Oppdatere CLAUDE.md/FEATURE_BACKLOG.md

Slette zip (6) etter fullført integrering
Alt levert og live:

Blind draw-skjermen — /tournaments/[id]/sessions/[sessionId], koblet fra Program-siden
Ny DELETE-endepunkt for å angre et deltakervalg før låsing
Synlighetshull tettet — org-admin så ikke lenger sine egne tilføyelser før reveal (own_team_ids() manglet samme owner/admin-utvidelse som skrive-siden fikk forrige runde)
500-krasjen fikset — manglende handicap-indeks gir nå en ren 400 i stedet for å krasje, med use_handicap=false fortsatt upåvirket
Alt scratch-verifisert i flere runder, test_isolation.sql 12/12, ekte typesjekket build, begge containere redeployet, teeoff.no upåvirket. Zip (6) slettet. .md-filene er oppdatert med hele runden.
This commit is contained in:
Erol Haagenrud 2026-07-18 17:15:50 +02:00
parent a46377e7bc
commit 02247a1e5f
11 changed files with 1141 additions and 20 deletions

View file

@ -250,7 +250,14 @@
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)",
"Bash(curl -s \"https://teecup.teeoff.no/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a?org=test-org-id&name=Test\")", "Bash(curl -s \"https://teecup.teeoff.no/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a?org=test-org-id&name=Test\")",
"Bash(python3 -m py_compile app/team_authz.py app/routers/matches.py app/routers/scoring.py)", "Bash(python3 -m py_compile app/team_authz.py app/routers/matches.py app/routers/scoring.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/owner_cookies.txt /tmp/member_cookies.txt /tmp/captain_cookies.txt)" "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/owner_cookies.txt /tmp/member_cookies.txt /tmp/captain_cookies.txt)",
"Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/tee_cookies.txt)",
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-6)",
"Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)",
"Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/del_cookies.txt /tmp/captvis_cookies.txt)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -799,10 +799,92 @@ Ferdig og verifisert:
uavhengig av org-rolle. `test_isolation.sql` 12/12. Ingen migrasjon (ren uavhengig av org-rolle. `test_isolation.sql` 12/12. Ingen migrasjon (ren
Python-endring) — kun `teecup_api` redeployet, `teeoff.no` upåvirket. Python-endring) — kun `teecup_api` redeployet, `teeoff.no` upåvirket.
- **Tee-endepunkter bygget og LIVE (2026-07-18), rett før blind draw-skjermen:**
fant et hull som blokkerte selve blind draw-flyten: `match_participant.
tee_id` er påkrevd, men det fantes INGEN `GET`-vei for å liste en banes
tee-er (selv offisielt importerte baner har tee-rader, men ingenting
eksponerte dem) OG egendefinerte («custom») baner har ALDRI hatt noen
vei til å FÅ tee-er i det hele tatt — dette er ikke noe ADR-019 innførte,
det var et hull som fantes fra før custom-baner ble lagt til i første
omgang, bare usynlig til nå. Konsekvens før fiksen: en turnering satt opp
på en manuelt navngitt bane kunne ALDRI få en ekte deltaker lagt til på
noen match. **Presisering (brukeren spurte eksplisitt):** dette er IKKE
noe som må løses i teeoff.no sin kode — egendefinerte baner er per
definisjon baner teeoff ikke kjenner til, så dette er en ren
teecup-intern funksjon, uavhengig av ADR-019-integrasjonen.
Ny `GET/POST /orgs/{id}/courses/{id}/tees` i `app/routers/courses.py`.
`POST` avviser eksplisitt forsøk på offisielle baner (400
`VALIDATION_FAILED` — de får tee-ene sine fra teeoff-importen, ikke
manuelt). Kun full_18-rating dekket (samme begrunnelse som ADR-019
Beslutning D). **Bevisst UTENFOR omfang denne runden, egen senere sak:**
hull-/stroke-index-data for egendefinerte baner (`hole`-tabellen forblir
tom for custom-baner) — trengs for korrekt slagfordeling i SCORING-fasen
(ADR-008), ikke i blind draw, så det løses naturlig når scorekort-
skjermen bygges (samme "bygg i rekkefølgen ting brukes"-logikk).
**Verifisert grundig i scratch:** tom tee-liste på fersk custom-bane,
opprett+list tee på custom-bane, offisiell bane viser alle 8 importerte
tee-er, manuell tee-opprettelse avvist på offisiell bane, og — den
faktiske payoff-en — en deltaker lagt til en match på en custom-bane-økt
for FØRSTE gang noensinne (`POST .../matches/{id}/participants` lykkes
nå med en tee_id fra en nyopprettet custom-tee). `test_isolation.sql`
12/12. Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no`
upåvirket.
- **Blind draw-skjermen LIVE (2026-07-18):** syvende V0-skjerm,
`components/session-blind-draw.tsx`, ny rute
`/tournaments/[id]/sessions/[sessionId]`. To lag-kolonner, hver med sine
matcher ("flights"), legg til/fjern spiller+tee per plass (antall plasser
avhenger av format), lås-knapp med bekreftelse, avslørings-visning når
begge lag har låst. Program-skjermens økt-kort er nå klikkbare inn hit.
**Datamodell tilpasset fra V0s mock til API-ets faktiske sett-modell:**
V0 designet faste, indekserte "Slot"-arrays (kontrollert skjema-state);
API-et har verken PATCH på `match_participant` eller noen slot-indeks —
kun opprett/slett av en løs deltaker-mengde per side. Løst med en
"legg til spiller"-inline-form (samme mønster som spiller-/bane-type-ahead
ellers i appen) i stedet for faste dropdown-rader; funksjonelt likeverdig,
strukturelt riktigere for API-ets faktiske form.
**To reelle hull funnet og fikset FØR/UNDER integrering:**
1. Ingen `DELETE` fantes for `match_participant` — en kaptein kunne aldri
angre et valg før låsing uten å etterlate en foreldreløs rad. Ny
`DELETE /orgs/{id}/matches/{match_id}/participants/{participant_id}`
(samme ALREADY_LOCKED/NOT_ROSTERED_ON_TEAM-sjekker som opprett).
2. **"Skriv blindt"-hull, funnet under selve scratch-testingen (ikke bare
tenkt ut på forhånd):** forrige rundes org-admin-overstyring i
`team_authz.py` lot en organisator LEGGE TIL deltakere på et lag de
ikke selv er rostret på, men `app/blind_draw.py` sin `own_team_ids()`
sjekket KUN rostret spiller for SYNLIGHET — så organisatoren så aldri
sine egne tilføyelser igjen før begge lag hadde låst. Fikset ved å gi
`own_team_ids()` samme owner/admin-utvidelse som `user_may_act_for_
team()` — en org-admin ser nå BEGGE lag umiddelbart (konsistent med at
de uansett allerede har full tilgang, se forrige rundes resonnement),
mens en faktisk rostret kaptein fortsatt kun ser sitt eget lag før
reveal. Kun ett reelt kallsted (`matches.py` sin `list_matches`).
**Tredje, urelatert bug fanget under samme scratch-økt og fikset på
brukerens eksplisitte forespørsel:** `POST .../matches/{id}/participants`
krasjet med en rå 500 (`TypeError` i `handicap_engine.py` sin
`course_handicap_raw`) hvis spilleren manglet `handicap_index` OG
`use_handicap` var på (standard) — preeksisterende, ikke noe denne
runden introduserte. Fikset i `app/routers/matches.py` sin
`add_participant`: sjekker nå `handicap_index_snapshot IS NULL` EKSPLISITT
FØR innsetting når `use_handicap` er sann, avviser med en klar
`VALIDATION_FAILED` (400) i stedet for å krasje. Bekreftet at
`use_handicap=false` fortsatt tillater en spiller uten handicap
(scratch-spill), og at en spiller MED handicap fortsatt fungerer uendret.
**Verifisert grundig i scratch, flere runder:** DELETE-endepunktet
(fjern+idempotent 404 ved gjentak+409 etter lås), synlighetsfikset (org-
admin ser begge sider, rostret kaptein ser fortsatt kun eget lag),
null-handicap-fikset (avvist rent, scratch-modus upåvirket, normal
spiller upåvirket), full opprett-match→legg-til-deltaker→lås→avslør-
syklus ende-til-ende. `test_isolation.sql` 12/12 etter hver runde. Ekte
typesjekket produksjonsbuild.
**Rullet ut live**, bruker bekreftet eksplisitt: begge containere
bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket.
Neste steg: Neste steg:
1. Blind draw-skjermen (neste i "bygg i rekkefølgen ting brukes", gjenopptatt 1. Scorekort-skjermen (neste i "bygg i rekkefølgen ting brukes") — her må
etter ADR-019-sidespranget) — deretter scorekort, leaderboard. Samme også hull-/stroke-index-data for egendefinerte baner løses (bevisst
mønster: utsatt fra tee-rundens tidligere status-notat). Deretter leaderboard.
Samme mønster:
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver
gang — diff mot live-treet i et scratch-område før noe pakkes ut over gang — diff mot live-treet i et scratch-område før noe pakkes ut over
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend

View file

@ -368,10 +368,33 @@
### Blind draw (skjult lagoppstilling) ### Blind draw (skjult lagoppstilling)
- **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert - **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
(`app/routers/matches.py`: synlighetsfilter i SQL, ikke Python-filter — se ADR-013). (`app/routers/matches.py`: synlighetsfilter i SQL, ikke Python-filter — se ADR-013).
**Frontend LIVE 2026-07-18:** `/tournaments/[id]/sessions/[sessionId]`,
`components/session-blind-draw.tsx`. Ny `DELETE .../matches/{id}/
participants/{id}` (kunne ikke angre et valg før låsing uten den). Fant
og fikset et "skriv blindt"-hull: org-admin kunne legge til deltakere på
et lag de ikke er rostret på (forrige rundes utvidelse), men
`own_team_ids()` i `app/blind_draw.py` viste dem aldri tilbake før
reveal — utvidet til samme owner/admin-regel som skrive-siden. Se
CLAUDE.md-status for full runde, inkl. en tredje, urelatert 500-bug
(manglende handicap-indeks) funnet og fikset samtidig.
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er - Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige. ferdige.
- **Gjenstår:** hvem som FÅR låse et lag er i dag bare «rostret på laget», ikke - **Gjenstår:** hvem som FÅR låse et lag er i dag «rostret på laget» ELLER
kaptein-spesifikt — se «Brukerroller» over. org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over), ikke
kaptein-spesifikt.
- **Nytt 2026-07-18, ✅ BYGGET (fant og fikset rett før frontend-skjermen):**
`match_participant.tee_id` er påkrevd, men det fantes INGEN vei til å
liste EN banes tee-er (selv offisielt importerte), og egendefinerte
baner har ALDRI hatt noen vei til å FÅ tee-er — et hull som fantes fra
før ADR-019, ikke noe den innførte. Uten dette kunne en turnering på en
manuelt navngitt bane aldri få en ekte deltaker på noen match. Ny
`GET/POST /orgs/{id}/courses/{id}/tees` (kun full_18-rating, offisielle
baner avviser manuell tee-opprettelse). **Presisert eksplisitt: dette er
IKKE en teeoff.no-kodeendring** — egendefinerte baner er per definisjon
utenfor teeoffs katalog, så dette er en ren teecup-intern funksjon.
**Bevisst utenfor omfang:** hull-/stroke-index-data for egendefinerte
baner (trengs i SCORING-fasen, ikke blind draw) — egen, senere sak når
scorekort-skjermen bygges.
### Forenklet scoreføring (uten slagtall) ### Forenklet scoreføring (uten slagtall)
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert - **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert

View file

@ -20,12 +20,16 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]:
return {r["team_id"] for r in rows} return {r["team_id"] for r in rows}
async def own_team_ids(conn: Connection, tournament_id: str, user_id: str) -> set[str]: async def own_team_ids(conn: Connection, organization_id: str, tournament_id: str, user_id: str) -> set[str]:
"""Hvilke(t) lag brukeren selv er rostret på i denne turneringen. """Hvilke lag brukeren FÅR SE deltakerne til, før begge lag har låst.
Går via player.user_id (nullable) -> team_roster -> team. En org-admin uten To uavhengige veier inn, samme par som team_authz.user_may_act_for_team
egen player-rad har ingen "eget lag" og får dermed tomt sett her bevisst, (2026-07-18 -- se den for full begrunnelse): rostret spiller (via
ikke en feil (se matches.py). player.user_id -> team_roster -> team) ELLER org-eier/admin, som da ser
BEGGE lag. Uten org-admin-veien kunne en organisator LEGGE TIL deltakere
(team_authz gir dem det) men aldri SE dem igjen før reveal -- en reell
"skriv blindt" felle funnet under blind draw-skjermens integrering, ikke
bare et tenkt tilfelle.
""" """
rows = await conn.fetch( rows = await conn.fetch(
""" """
@ -38,4 +42,20 @@ async def own_team_ids(conn: Connection, tournament_id: str, user_id: str) -> se
tournament_id, tournament_id,
user_id, user_id,
) )
return {r["team_id"] for r in rows} own = {r["team_id"] for r in rows}
is_org_admin = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM organization_membership
WHERE organization_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)
""",
organization_id,
user_id,
)
if is_org_admin:
all_teams = await conn.fetch("SELECT id::text FROM team WHERE tournament_id = $1", tournament_id)
own |= {r["id"] for r in all_teams}
return own

View file

@ -62,6 +62,111 @@ async def create_course(
return Course(**dict(row)) return Course(**dict(row))
# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) --
#
# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte
# baner har tee-rader, men ingenting eksponerte dem) eller en opprett-vei for
# egendefinerte baner (de har ALDRI hatt noen vei til tee-data, uavhengig av
# ADR-019). Reist av brukeren rett før blind draw-skjermen: uten dette kan en
# match aldri få en deltaker (tee_id er påkrevd, ingen gyldig verdi finnes).
# Kun full_18-rating dekket her, samme begrunnelse som ADR-019 Beslutning D.
# Hull-/stroke-index-data for egendefinerte baner (trengs for korrekt
# slagfordeling i SCORING-fasen, ikke i blind draw) er bevisst UTENFOR
# omfang denne runden -- egen, senere sak når scorekort-skjermen bygges.
class TeeCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
gender: str | None = Field(default=None, pattern="^[mfx]$")
course_rating: float = Field(gt=0)
slope_rating: int = Field(ge=55, le=155)
par: int = Field(ge=27, le=90)
class Tee(BaseModel):
id: str
name: str
gender: str | None
course_rating: float
slope_rating: int
par: int
_TEE_COLUMNS = """
t.id::text, t.name, t.gender, tr.course_rating::float AS course_rating,
tr.slope_rating, tr.par
"""
@router.get("/orgs/{organization_id}/courses/{course_id}/tees", response_model=list[Tee])
async def list_tees(
course_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[Tee]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
f"""
SELECT {_TEE_COLUMNS}
FROM tee t
JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
WHERE t.course_id = $1
ORDER BY t.name
""",
course_id,
)
return [Tee(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/courses/{course_id}/tees", response_model=Tee, status_code=201)
async def create_tee(
course_id: str,
body: TeeCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tee:
async with org_connection(organization_id) as conn, translate_db_errors():
source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id)
if source is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
if source == "official":
raise app_error(
400,
"VALIDATION_FAILED",
"Offisielle baner får tee-ene sine fra teeoff -- kan ikke legges til manuelt.",
)
tee_row = await conn.fetchrow(
"""
INSERT INTO tee (organization_id, course_id, name, gender)
VALUES ($1, $2, $3, $4)
RETURNING id::text
""",
organization_id,
course_id,
body.name,
body.gender,
)
rating_row = await conn.fetchrow(
"""
INSERT INTO tee_rating (organization_id, tee_id, scope, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5)
RETURNING course_rating::float AS course_rating, slope_rating, par
""",
organization_id,
tee_row["id"],
body.course_rating,
body.slope_rating,
body.par,
)
return Tee(
id=tee_row["id"],
name=body.name,
gender=body.gender,
course_rating=rating_row["course_rating"],
slope_rating=rating_row["slope_rating"],
par=rating_row["par"],
)
# --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- # --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------

View file

@ -162,7 +162,11 @@ async def list_matches(
locked = await locked_team_ids(conn, session_id) locked = await locked_team_ids(conn, session_id)
revealed = len(locked) >= 2 revealed = len(locked) >= 2
# Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant). # Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant).
own = set() if revealed else await own_team_ids(conn, session["tournament_id"], user.user_id) own = (
set()
if revealed
else await own_team_ids(conn, organization_id, session["tournament_id"], user.user_id)
)
participant_rows = await conn.fetch( participant_rows = await conn.fetch(
""" """
@ -276,6 +280,28 @@ async def add_participant(
409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten."
) )
# Beregnet HELT FØR innsetting -- se app/handicap.py sin
# compute_and_store_side_handicaps, som ellers krasjer rått
# (TypeError i handicap_engine.py sin course_handicap_raw) på en
# spiller uten handicap_index når use_handicap er på (standard).
# Fant under scratch-testing av blind draw-skjermen -- reell,
# preeksisterende feil, ikke noe denne runden introduserte.
allowance_override = (
json.loads(session["allowance_override"]) if session["allowance_override"] else None
)
config = parse_allowance_config(session["format"], allowance_override)
if config.use_handicap:
handicap_index_snapshot = await conn.fetchval(
"SELECT handicap_index_snapshot FROM team_roster WHERE id = $1", body.team_roster_id
)
if handicap_index_snapshot is None:
raise app_error(
400,
"VALIDATION_FAILED",
"Denne spilleren mangler handicap-indeks -- sett en verdi på spilleren "
"før hen kan legges til i en handicap-basert økt.",
)
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
WITH inserted AS ( WITH inserted AS (
@ -302,17 +328,58 @@ async def add_participant(
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er # Beregn/lagre course_handicap/playing_handicap nå som deltakeren er
# satt inn. Funksjonen avgjør selv om siden er "klar" (singles/ # satt inn. Funksjonen avgjør selv om siden er "klar" (singles/
# fourball: alltid; foursome/greensome/scramble: kun når siden er # fourball: alltid; foursome/greensome/scramble: kun når siden er
# komplett) -- se app/handicap.py. # komplett) -- se app/handicap.py. `config` allerede parset over.
allowance_override = (
json.loads(session["allowance_override"]) if session["allowance_override"] else None
)
config = parse_allowance_config(session["format"], allowance_override)
await compute_and_store_side_handicaps( await compute_and_store_side_handicaps(
conn, match_id, body.team_side, session["format"], session["hole_config"], config conn, match_id, body.team_side, session["format"], session["hole_config"], config
) )
return MatchParticipantOut(**dict(row)) return MatchParticipantOut(**dict(row))
@router.delete(
"/orgs/{organization_id}/matches/{match_id}/participants/{participant_id}",
status_code=204,
)
async def remove_participant(
match_id: str,
participant_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Fjern et deltaker-valg FØR laget har låst -- lar en kaptein endre seg
(velge en annen spiller/tee for samme plass) uten å etterlate en
foreldreløs rad, siden det ikke finnes noen PATCH match_participant.
Reist av brukeren rett før blind draw-skjermen (som lar spiller/tee
endres fritt i UI-et helt frem til låsing)."""
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"""
SELECT mp.team_side::text AS team_side,
m.session_id::text AS session_id,
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
WHERE mp.id = $1 AND mp.match_id = $2
""",
participant_id,
match_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
team_id = row["team_a_id"] if row["team_side"] == "a" else row["team_b_id"]
if not await user_may_act_for_team(conn, organization_id, team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
locked = await locked_team_ids(conn, row["session_id"])
if team_id in locked:
raise app_error(
409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten."
)
await conn.execute("DELETE FROM match_participant WHERE id = $1", participant_id)
class LockCreate(BaseModel): class LockCreate(BaseModel):
team_id: str team_id: str

View file

@ -0,0 +1,34 @@
import { SessionBlindDraw } from "@/components/session-blind-draw"
export default async function SessionBlindDrawPage({
params,
searchParams,
}: {
params: Promise<{ id: string; sessionId: string }>
searchParams: Promise<{ org?: string; name?: string }>
}) {
const { id, sessionId } = await params
const { org, name } = await searchParams
const tournamentName = name?.trim() || "Turnering"
// Samme grunn som de andre turnering-undersidene: API-et krever
// organization_id på alle økt-/match-endepunkter (RLS, se ADR-001/003).
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<SessionBlindDraw
organizationId={org}
tournamentId={id}
sessionId={sessionId}
tournamentName={tournamentName}
/>
)
}

View file

@ -0,0 +1,777 @@
"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Lock, PartyPopper, Plus, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py)
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
type HoleConfig = "full_18" | "front_9" | "back_9"
type ScoringMode = "stroke" | "hole_result"
type ApiTeam = { id: string; name: string; color: string | null }
type ApiRosterEntry = {
id: string
player_id: string
display_name: string
handicap_index_snapshot: number | null
is_captain: boolean
}
type ApiTee = {
id: string
name: string
gender: "m" | "f" | "x" | null
course_rating: number
slope_rating: number
par: number
}
type ApiSession = {
id: string
name: string | null
format: Format
hole_config: HoleConfig
course_id: string
points_per_match: number
scoring_mode: ScoringMode
locked_team_ids: string[]
}
type ApiParticipant = {
id: string
team_side: "a" | "b"
team_roster_id: string
player_name: string
tee_id: string
tee_name: string
}
type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
tee_time: string | null
participants: ApiParticipant[]
}
// --- Labels ------------------------------------------------------------------
const FORMAT_LABELS: Record<Format, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
}
const HOLE_LABELS: Record<HoleConfig, string> = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" }
const SCORING_LABELS: Record<ScoringMode, string> = { stroke: "Slag for slag", hole_result: "Kun hullresultat" }
function slotsPerSide(format: Format): number {
if (format === "singles") return 1
if (format === "scramble_4") return 4
return 2
}
// --- Component ---------------------------------------------------------------
export function SessionBlindDraw({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [session, setSession] = useState<ApiSession | null>(null)
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({})
const [tees, setTees] = useState<ApiTee[]>([])
const [matches, setMatches] = useState<ApiMatch[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmingLock, setConfirmingLock] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [sessionsRes, teamsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
])
if (!sessionsRes.ok || !teamsRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json()
const foundSession = sessionsData.find((s) => s.id === sessionId)
if (!foundSession) throw new Error("session not found")
const teamsData: ApiTeam[] = await teamsRes.json()
if (teamsData.length !== 2) throw new Error("expected 2 teams")
const [rosterARes, rosterBRes, teesRes, matchesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
])
if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed")
if (cancelled) return
setSession(foundSession)
setTeams([teamsData[0], teamsData[1]])
setRosters({
[teamsData[0].id]: await rosterARes.json(),
[teamsData[1].id]: await rosterBRes.json(),
})
setTees(await teesRes.json())
setMatches(await matchesRes.json())
} catch {
if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, sessionId])
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches],
)
const bothLocked = (session?.locked_team_ids.length ?? 0) >= 2
async function refetchMatches() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" })
if (res.ok) setMatches(await res.json())
}
async function addMatch() {
if (!teams) return
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
})
if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json()
setMatches((prev) => [...prev, created])
} catch {
setError("Klarte ikke å legge til match. Prøv igjen.")
}
}
async function addParticipant(matchId: string, teamSide: "a" | "b", teamRosterId: string, teeId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ team_side: teamSide, team_roster_id: teamRosterId, tee_id: teeId }),
})
if (!res.ok) throw new Error(`add participant: ${res.status}`)
const created: ApiParticipant = await res.json()
setMatches((prev) =>
prev.map((m) => (m.id === matchId ? { ...m, participants: [...m.participants, created] } : m)),
)
} catch {
setError("Klarte ikke å legge til spilleren. Kanskje laget allerede har låst, eller du mangler tilgang.")
}
}
async function removeParticipant(matchId: string, participantId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants/${participantId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) throw new Error(`remove participant: ${res.status}`)
setMatches((prev) =>
prev.map((m) =>
m.id === matchId
? { ...m, participants: m.participants.filter((p) => p.id !== participantId) }
: m,
),
)
} catch {
setError("Klarte ikke å fjerne spilleren. Prøv igjen.")
}
}
async function lockTeam(teamId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/lock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ team_id: teamId }),
})
if (!res.ok) throw new Error(`lock: ${res.status}`)
setSession((prev) =>
prev ? { ...prev, locked_team_ids: [...prev.locked_team_ids, teamId] } : prev,
)
// Motstanderens deltakere er skjult server-side inntil begge har låst
// (ADR-013) -- hent matchene på nytt for å få den avslørte visningen
// hvis dette var det andre laget som nettopp låste.
await refetchMatches()
} catch {
setError("Klarte ikke å låse oppstillingen. Prøv igjen.")
} finally {
setConfirmingLock(null)
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (loading || !session || !teams) {
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-5xl flex-1 items-center justify-center px-5 py-6">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : (
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
)}
</main>
</div>
)
}
const perSide = slotsPerSide(session.format)
function usedPlayerIds(teamId: string, exceptMatchId: string): Set<string> {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
const used = new Set<string>()
for (const m of matches) {
if (m.id === exceptMatchId) continue
for (const p of m.participants) {
if (p.team_side === side) used.add(p.team_roster_id)
}
}
return used
}
function teamIsComplete(teamId: string): boolean {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
if (matches.length === 0) return false
return matches.every((m) => m.participants.filter((p) => p.team_side === side).length >= perSide)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
<span className="truncate text-sm text-muted-foreground">
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]}
</span>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-4 flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
<StatusBanner teams={teams} lockedTeamIds={session.locked_team_ids} bothLocked={bothLocked} />
{bothLocked ? (
<RevealedView teams={teams} matches={sortedMatches} />
) : (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{teams.map((team, teamIndex) => (
<TeamColumn
key={team.id}
team={team}
side={teamIndex === 0 ? "a" : "b"}
roster={rosters[team.id] ?? []}
tees={tees}
matches={sortedMatches}
perSide={perSide}
locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant}
onRemoveParticipant={removeParticipant}
onAddMatch={addMatch}
complete={teamIsComplete(team.id)}
confirming={confirmingLock === team.id}
onRequestLock={() => setConfirmingLock(team.id)}
onCancelLock={() => setConfirmingLock(null)}
onConfirmLock={() => lockTeam(team.id)}
/>
))}
</div>
)}
</main>
</div>
)
}
// --- Status banner -------------------------------------------------------------
function StatusBanner({
teams,
lockedTeamIds,
bothLocked,
}: {
teams: [ApiTeam, ApiTeam]
lockedTeamIds: string[]
bothLocked: boolean
}) {
if (bothLocked) {
return (
<div className="flex items-center gap-3 rounded-3xl bg-primary px-5 py-4 text-primary-foreground shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
<PartyPopper aria-hidden="true" className="size-6" />
</div>
<div className="flex flex-col">
<span className="text-base font-extrabold tracking-tight">Oppstilling avslørt!</span>
<span className="text-sm text-primary-foreground/80">
Begge lag har låst. Her er paringene for økten.
</span>
</div>
</div>
)
}
const lockedCount = lockedTeamIds.length
if (lockedCount === 1) {
const lockedTeam = lockedTeamIds.includes(teams[0].id) ? teams[0] : teams[1]
const otherTeam = lockedTeam.id === teams[0].id ? teams[1] : teams[0]
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<Clock aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
{lockedTeam.name} har låst
</span>
<span className="text-sm text-muted-foreground">
Venter at {otherTeam.name} låser sin oppstilling.
</span>
</div>
</div>
)
}
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<EyeOff aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground">Begge lag setter opp i skjul</span>
<span className="text-sm text-muted-foreground text-pretty">
Ingen ser motstanderens valg før begge har låst oppstillingen.
</span>
</div>
</div>
)
}
// --- Team column ---------------------------------------------------------------
function TeamColumn({
team,
side,
roster,
tees,
matches,
perSide,
locked,
usedPlayerIds,
onAddParticipant,
onRemoveParticipant,
onAddMatch,
complete,
confirming,
onRequestLock,
onCancelLock,
onConfirmLock,
}: {
team: ApiTeam
side: "a" | "b"
roster: ApiRosterEntry[]
tees: ApiTee[]
matches: ApiMatch[]
perSide: number
locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: (matchId: string, side: "a" | "b", teamRosterId: string, teeId: string) => void
onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: () => void
complete: boolean
confirming: boolean
onRequestLock: () => void
onCancelLock: () => void
onConfirmLock: () => void
}) {
const color = team.color ?? "#64748b"
return (
<section
className="flex flex-col gap-3 rounded-3xl border border-border bg-card/40 p-4 shadow-sm shadow-black/5 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: color }}
aria-label={`Oppstilling for ${team.name}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<span aria-hidden="true" className="size-4 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{team.name}</h2>
</div>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Users aria-hidden="true" className="size-4" />
{matches.length} {matches.length === 1 ? "match" : "matcher"}
</span>
</div>
{locked && (
<div className="inline-flex w-fit items-center gap-1.5 rounded-full bg-secondary px-3 py-1 text-xs font-bold text-secondary-foreground">
<Lock aria-hidden="true" className="size-3.5" />
Låst
</div>
)}
<div className="flex flex-col gap-3">
{matches.map((match) => {
const sideParticipants = match.participants.filter((p) => p.team_side === side)
const disabled = usedPlayerIds(team.id, match.id)
return (
<div key={match.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{sideParticipants.map((p) => (
<div
key={p.id}
className="flex items-center justify-between gap-3 rounded-xl bg-secondary/60 px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<div className="flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-card px-2.5 py-1 text-xs font-semibold text-foreground">
{p.tee_name}
</span>
{!locked && (
<button
type="button"
onClick={() => onRemoveParticipant(match.id, p.id)}
aria-label={`Fjern ${p.player_name}`}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
))}
{!locked && sideParticipants.length < perSide && (
<AddSlotForm
roster={roster}
tees={tees}
disabledPlayerIds={disabled}
onAdd={(teamRosterId, teeId) => onAddParticipant(match.id, side, teamRosterId, teeId)}
/>
)}
{perSide > 1 && (
<p className="text-xs text-muted-foreground">{perSide} spillere per side i dette formatet</p>
)}
</div>
</div>
)
})}
</div>
{!locked && (
<button
type="button"
onClick={onAddMatch}
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Legg til match
</button>
)}
{!locked && (
<div className="mt-1">
{confirming ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/60 p-4">
<p className="text-sm font-medium text-foreground text-pretty">
Er du sikker? Du kan ikke endre oppstillingen etter at du har låst.
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancelLock} className="h-11 flex-1 rounded-2xl font-semibold">
Avbryt
</Button>
<Button type="button" onClick={onConfirmLock} className="h-11 flex-1 rounded-2xl font-bold">
<Lock aria-hidden="true" className="size-4" />
Lås
</Button>
</div>
</div>
) : (
<Button
type="button"
onClick={onRequestLock}
disabled={!complete}
className="h-12 w-full rounded-2xl text-base font-bold shadow-sm"
>
<Lock aria-hidden="true" className="size-5" />
Lås oppstilling
</Button>
)}
{!complete && !confirming && (
<p className="mt-2 text-center text-xs text-muted-foreground text-pretty">
Velg spiller og tee for alle plasser før du kan låse.
</p>
)}
</div>
)}
</section>
)
}
// --- Add-slot inline form --------------------------------------------------
function AddSlotForm({
roster,
tees,
disabledPlayerIds,
onAdd,
}: {
roster: ApiRosterEntry[]
tees: ApiTee[]
disabledPlayerIds: Set<string>
onAdd: (teamRosterId: string, teeId: string) => void
}) {
const [rosterId, setRosterId] = useState("")
const [teeId, setTeeId] = useState("")
function handleAdd() {
if (!rosterId || !teeId) return
onAdd(rosterId, teeId)
setRosterId("")
setTeeId("")
}
return (
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 rounded-xl border border-dashed border-border bg-background/20 p-2">
<label className="flex flex-col gap-1">
<span className="sr-only">Velg spiller</span>
<select
value={rosterId}
onChange={(e) => setRosterId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg spiller</option>
{roster.map((p) => (
<option key={p.id} value={p.id} disabled={disabledPlayerIds.has(p.id)}>
{p.display_name}
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
{disabledPlayerIds.has(p.id) ? " — valgt" : ""}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="sr-only">Velg tee</span>
<select
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg tee</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{teeLabel(t)}
</option>
))}
</select>
</label>
<Button
type="button"
size="icon"
disabled={!rosterId || !teeId}
onClick={handleAdd}
className="h-11 w-11 shrink-0 rounded-lg"
aria-label="Legg til spiller"
>
<Plus aria-hidden="true" className="size-4" />
</Button>
</div>
)
}
// --- Revealed view ---------------------------------------------------------
function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches: ApiMatch[] }) {
return (
<div className="mt-6 flex flex-col gap-3">
{matches.map((match) => (
<div
key={match.id}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"
>
<div className="flex items-center justify-between gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2 border-t border-border pt-3 sm:gap-3">
<RevealSide team={teams[0]} participants={match.participants.filter((p) => p.team_side === "a")} align="left" />
<div className="flex items-center justify-center">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide text-secondary-foreground">
vs
</span>
</div>
<RevealSide team={teams[1]} participants={match.participants.filter((p) => p.team_side === "b")} align="right" />
</div>
</div>
))}
</div>
)
}
function RevealSide({
team,
participants,
align,
}: {
team: ApiTeam
participants: ApiParticipant[]
align: "left" | "right"
}) {
const right = align === "right"
const color = team.color ?? "#64748b"
return (
<div
className={cn("flex flex-col gap-2 rounded-2xl bg-background/50 p-3", right ? "items-end text-right" : "items-start text-left")}
style={right ? { borderRightWidth: 4, borderRightColor: color } : { borderLeftWidth: 4, borderLeftColor: color }}
>
<div className={cn("flex items-center gap-2", right && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{team.name}</span>
</div>
{participants.map((p) => (
<div key={p.id} className={cn("flex flex-col", right ? "items-end" : "items-start")}>
<span className="text-sm font-semibold text-foreground text-pretty">{p.player_name}</span>
<span className="text-xs text-muted-foreground">{p.tee_name}</span>
</div>
))}
</div>
)
}
// --- Small helpers ---------------------------------------------------------
function MetaBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{children}
</span>
)
}
function teeLabel(tee: ApiTee) {
const genderMap: Record<"m" | "f" | "x", string> = { m: "H", f: "D", x: "N" }
const g = tee.gender ? ` ${genderMap[tee.gender]}` : ""
return `${tee.name}${g}`
}
function formatHcp(value: number | null) {
if (value === null) return ""
return value.toFixed(1).replace(".", ",")
}
function formatTime(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}

View file

@ -271,6 +271,7 @@ export function TournamentProgram({
session={session} session={session}
course={coursesById.get(session.course_id) ?? null} course={coursesById.get(session.course_id) ?? null}
isLast={index === sorted.length - 1} isLast={index === sorted.length - 1}
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
/> />
))} ))}
</ol> </ol>
@ -298,10 +299,12 @@ function SessionCard({
session, session,
course, course,
isLast, isLast,
href,
}: { }: {
session: ApiSession session: ApiSession
course: ApiCourse | null course: ApiCourse | null
isLast: boolean isLast: boolean
href: string
}) { }) {
const title = session.name?.trim() || `Økt ${session.sequence}` const title = session.name?.trim() || `Økt ${session.sequence}`
@ -314,7 +317,10 @@ function SessionCard({
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />} {!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
</div> </div>
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5"> <Link
href={href}
className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50"
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3> <h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -350,7 +356,7 @@ function SessionCard({
</div> </div>
)} )}
</div> </div>
</div> </Link>
</li> </li>
) )
} }