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:
parent
a46377e7bc
commit
02247a1e5f
11 changed files with 1141 additions and 20 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
88
CLAUDE.md
88
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 på 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
|
||||||
|
|
||||||
|
|
|
||||||
34
frontend/app/tournaments/[id]/sessions/[sessionId]/page.tsx
Normal file
34
frontend/app/tournaments/[id]/sessions/[sessionId]/page.tsx
Normal 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. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionBlindDraw
|
||||||
|
organizationId={org}
|
||||||
|
tournamentId={id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
tournamentName={tournamentName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
777
frontend/components/session-blind-draw.tsx
Normal file
777
frontend/components/session-blind-draw.tsx
Normal 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 på 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(".", ",")
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in a new issue