Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session) Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx Bingo Bango Bongo: format-valg + per-hull picker + poengtabell Flaggturnering: format-valg + nedtelling/resultatvisning Shamble: format-valg + best_n + lagvisning (frittstående + org-lag) Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag) High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater) Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter) Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute) Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
This commit is contained in:
parent
7ae554062c
commit
968ea79184
18 changed files with 1523 additions and 102 deletions
|
|
@ -381,7 +381,8 @@
|
||||||
"Bash(python3 test_round_edit_delete.py)",
|
"Bash(python3 test_round_edit_delete.py)",
|
||||||
"Bash(python3 test_round_starthole_time_nearby.py)",
|
"Bash(python3 test_round_starthole_time_nearby.py)",
|
||||||
"Bash(python3 test_round_strokes_received.py)",
|
"Bash(python3 test_round_strokes_received.py)",
|
||||||
"Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)"
|
"Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)",
|
||||||
|
"mcp__chrome-devtools__get_network_request"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
68
CLAUDE.md
68
CLAUDE.md
|
|
@ -5855,8 +5855,8 @@ Ferdig og verifisert:
|
||||||
rent, `/health`/`/dashboard`/scorekort-siden → 200, `teeoff.no`
|
rent, `/health`/`/dashboard`/scorekort-siden → 200, `teeoff.no`
|
||||||
upåvirket.
|
upåvirket.
|
||||||
|
|
||||||
- **Åtte nye turneringsformater: BACKEND FERDIG OG SCRATCH-VERIFISERT,
|
- **Åtte nye turneringsformater: BACKEND FERDIG, SCRATCH-VERIFISERT OG
|
||||||
IKKE ENNÅ RULLET UT (2026-07-30):** brukeren ba om å ta fatt på det
|
RULLET UT LIVE (2026-07-30):** brukeren ba om å ta fatt på det
|
||||||
lenge noterte "flere turneringsformater"-punktet fra 2026-07-19. Listen
|
lenge noterte "flere turneringsformater"-punktet fra 2026-07-19. Listen
|
||||||
ble utvidet fra fire til åtte (Shamble, Chapman/Pinehurst, Bingo Bango
|
ble utvidet fra fire til åtte (Shamble, Chapman/Pinehurst, Bingo Bango
|
||||||
Bongo, Money Ball/Lone Ranger, Nassau Match Play lagt til; "High-low-
|
Bongo, Money Ball/Lone Ranger, Nassau Match Play lagt til; "High-low-
|
||||||
|
|
@ -5902,10 +5902,55 @@ Ferdig og verifisert:
|
||||||
hele runden (migrasjonene 041-050 er rent additive).
|
hele runden (migrasjonene 041-050 er rent additive).
|
||||||
**IKKE bygget ennå, bevisst neste steg:** frontend for samtlige åtte
|
**IKKE bygget ennå, bevisst neste steg:** frontend for samtlige åtte
|
||||||
formater (nye format-valg i opprett-runde/opprett-økt, nye
|
formater (nye format-valg i opprett-runde/opprett-økt, nye
|
||||||
resultatvisninger). **Migrasjoner 041-050 BYGGET OG SCRATCH-VERIFISERT,
|
resultatvisninger) — backend er fullt funksjonelt og testet, men
|
||||||
IKKE ENNÅ rullet ut mot ekte `teecup_db`** -- venter på eksplisitt
|
ubrukelig fra selve appen inntil frontend bygges, samme lagdelings-
|
||||||
bekreftelse fra bruker. Se FEATURE_BACKLOG.md for full detalj per
|
mønster som ADR-037/038/039.
|
||||||
format.
|
**Rullet ut mot ekte systemer 2026-07-30**, bruker bekreftet eksplisitt
|
||||||
|
("Ja takk"): migrasjonene 041-050 kjørt i rekkefølge mot ekte
|
||||||
|
`teecup_db` (alle 10 rene, additive `DROP/ADD CONSTRAINT`+nye
|
||||||
|
tabeller/kolonner, ingen eksisterende rader rørt), `test_isolation.sql`
|
||||||
|
fortsatt 12/12, deretter `docker compose up -d --build teecup_api`.
|
||||||
|
Containeren boot-et rent (`Application startup complete`), `/health`/
|
||||||
|
`/dashboard` → 200, `teeoff.no` upåvirket. Verifisert presist at det nye
|
||||||
|
Nassau-endepunktet faktisk når FastAPI gjennom hele produksjonsstacken:
|
||||||
|
et anonymt kall ga korrekt `401 NOT_AUTHENTICATED`, ikke en rå 404.
|
||||||
|
Scratch-miljøet (isolert rolle/database/MinIO/API-container, alle
|
||||||
|
test-skript) ryddet opp etterpå, som vanlig. Se FEATURE_BACKLOG.md for
|
||||||
|
full detalj per format.
|
||||||
|
|
||||||
|
- **Driftsvarsel ved ny konto, BYGGET, SCRATCH-VERIFISERT OG LIVE
|
||||||
|
(2026-07-30):** brukeren ba om en e-post til seg selv
|
||||||
|
(`hei@erol.no`) hver gang noen oppretter en HELT NY TeeCup-konto —
|
||||||
|
avklart eksplisitt (AskUserQuestion) at dette gjelder kontoopprettelse
|
||||||
|
generelt (ikke turnering-selvregistrering, ADR-017, som var det andre
|
||||||
|
alternativet). Ingen migrasjon — ren kode-endring.
|
||||||
|
Ny `settings.NEW_ACCOUNT_ALERT_EMAIL` (`app/config.py`, env-variabel
|
||||||
|
`TEECUP_NEW_ACCOUNT_ALERT_EMAIL`, default `hei@erol.no` — egen setting
|
||||||
|
fremfor hardkodet adresse, kan endres uten ny utrulling). Ny
|
||||||
|
`send_new_account_alert_email()` i `app/email.py` (alltid norsk — internt
|
||||||
|
driftsvarsel til én fast, kjent mottaker, ikke brukervendt i18n-tekst).
|
||||||
|
**Kjernestykket:** `verify_magic_link` (`app/routers/auth.py`) er den
|
||||||
|
ENESTE plassen en `app_user`-rad noensinne settes inn (bekreftet med
|
||||||
|
`grep -rn "INSERT INTO app_user"` — null andre treff) — utvidet til å
|
||||||
|
fange `is_new_account` (sann KUN når selve INSERT-en faktisk vant, ikke
|
||||||
|
ved en samtidig konflikt ELLER en sekundær-e-post-innlogging som løses
|
||||||
|
til en eksisterende konto, `FEATURE_BACKLOG.md` "Én person, flere
|
||||||
|
e-postadresser"). Selve e-postutsendingen skjer BEVISST ETTER at
|
||||||
|
`plain_connection()`-blokken er lukket (samme "e-post skal aldri sendes
|
||||||
|
mens en tilkobling/transaksjon holdes åpen"-prinsipp som resten av
|
||||||
|
appen), med samme `DEV_LOG_MAGIC_LINKS`/`SMTP_CONFIGURED`/try-except-
|
||||||
|
mønster som all annen e-postutsending — en driftsfeil i selve
|
||||||
|
sendingen kan aldri endre innloggingsresponsen.
|
||||||
|
**Scratch-verifisert, 8/8 sjekker:** varsel logget nøyaktig én gang ved
|
||||||
|
aller første innlogging for en ny e-post, INGEN nytt varsel ved en
|
||||||
|
påfølgende innlogging for SAMME konto, og et helt nytt, eget varsel for
|
||||||
|
en ANNEN ny e-post (uendret telling for den første). `test_isolation.sql`
|
||||||
|
12/12 uendret (ingen skjemaendring).
|
||||||
|
**Rullet ut live 2026-07-30**, sammen med migrasjonene 041-050 over (én
|
||||||
|
samlet utrulling, bruker bekreftet eksplisitt): ingen migrasjon for
|
||||||
|
denne delen isolert, dekket av samme `docker compose up -d --build
|
||||||
|
teecup_api`-kjøring, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
|
|
@ -6158,3 +6203,14 @@ Neste steg:
|
||||||
reell innvilget tillatelse (automatisert testmiljø hadde
|
reell innvilget tillatelse (automatisert testmiljø hadde
|
||||||
`Notification.permission` forhåndssatt til "denied") — bruker bør
|
`Notification.permission` forhåndssatt til "denied") — bruker bør
|
||||||
selv teste push på en ekte enhet.
|
selv teste push på en ekte enhet.
|
||||||
|
14. **Åtte nye turneringsformater (Chapman/Nassau/Københavner/Bingo Bango
|
||||||
|
Bongo/Flaggturnering/Shamble/Money Ball/High-low-high) — BACKEND
|
||||||
|
FERDIG, SCRATCH-VERIFISERT OG LIVE (2026-07-30, migrasjoner 041-050),
|
||||||
|
se status over.** Eneste gjenstående steg: FRONTEND for samtlige åtte
|
||||||
|
— nye spilleform-valg i opprett-runde/opprett-økt (frittstående
|
||||||
|
runder + org-lag + org-individuell, avhengig av format), nye
|
||||||
|
resultatvisninger (Nassau-vinduer, Københavner/BBB-poengtabeller,
|
||||||
|
Flag-nedtelling, Shamble/Money Ball-lagvisning, High-low-high-løpende
|
||||||
|
poeng). Backend er fullt funksjonelt og testet, men ubrukelig fra
|
||||||
|
selve appen inntil dette bygges — samme lagdelings-rekkefølge
|
||||||
|
(motor→skjema→API→frontend) som ADR-037/038/039.
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ class Settings:
|
||||||
# scratch-/dev-testing kan overstyre uten kodeendring.
|
# scratch-/dev-testing kan overstyre uten kodeendring.
|
||||||
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
|
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
|
||||||
|
|
||||||
|
# Driftsvarsel til appens eier hver gang en HELT NY TeeCup-konto
|
||||||
|
# opprettes (2026-07-30, reist av bruker) -- ikke en hemmelighet, bare
|
||||||
|
# en fast mottakeradresse. Egen setting (ikke hardkodet i selve
|
||||||
|
# koden) slik at adressen kan endres uten en ny utrulling.
|
||||||
|
NEW_ACCOUNT_ALERT_EMAIL: str = os.getenv("TEECUP_NEW_ACCOUNT_ALERT_EMAIL", "hei@erol.no")
|
||||||
|
|
||||||
# MinIO (ADR-018 MinIO-runde). Root-credentials -- ekte hemmeligheter,
|
# MinIO (ADR-018 MinIO-runde). Root-credentials -- ekte hemmeligheter,
|
||||||
# _required. INTERNAL_ENDPOINT (container-til-container, ingen Caddy)
|
# _required. INTERNAL_ENDPOINT (container-til-container, ingen Caddy)
|
||||||
# brukes KUN til engangs bucket-/policy-oppsett ved oppstart.
|
# brukes KUN til engangs bucket-/policy-oppsett ved oppstart.
|
||||||
|
|
|
||||||
16
app/email.py
16
app/email.py
|
|
@ -320,3 +320,19 @@ async def send_session_result_email(
|
||||||
token=raw_token,
|
token=raw_token,
|
||||||
)
|
)
|
||||||
await to_thread(_send_sync, to_email, subject, body)
|
await to_thread(_send_sync, to_email, subject, body)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_new_account_alert_email(to_email: str, new_user_email: str, display_name: str) -> None:
|
||||||
|
"""Driftsvarsel til appens eier (2026-07-30) hver gang en HELT NY
|
||||||
|
TeeCup-konto opprettes (app/routers/auth.py sin verify_magic_link --
|
||||||
|
den ENESTE plassen en app_user-rad noensinne settes inn). Alltid
|
||||||
|
norsk -- et internt driftsvarsel til én fast, kjent mottaker, ikke en
|
||||||
|
brukervendt i18n-tekst."""
|
||||||
|
subject = "Ny TeeCup-konto opprettet"
|
||||||
|
body = (
|
||||||
|
f"Hei,\n\n"
|
||||||
|
f"En ny konto ble nettopp opprettet i TeeCup:\n\n"
|
||||||
|
f"E-post: {new_user_email}\n"
|
||||||
|
f"Navn: {display_name}\n"
|
||||||
|
)
|
||||||
|
await to_thread(_send_sync, to_email, subject, body)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ from ..db import org_connection, plain_connection
|
||||||
from ..email import (
|
from ..email import (
|
||||||
send_email_change_confirmation,
|
send_email_change_confirmation,
|
||||||
send_magic_link_email,
|
send_magic_link_email,
|
||||||
|
send_new_account_alert_email,
|
||||||
send_secondary_email_verification,
|
send_secondary_email_verification,
|
||||||
send_two_factor_code_email,
|
send_two_factor_code_email,
|
||||||
)
|
)
|
||||||
|
|
@ -257,10 +258,18 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
secondary_owner_id = await conn.fetchval(
|
secondary_owner_id = await conn.fetchval(
|
||||||
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
|
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
|
||||||
)
|
)
|
||||||
|
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
|
||||||
|
# kontoens navn trengs ETTER at connection-blokken er lukket
|
||||||
|
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling
|
||||||
|
# holdes åpen) -- fanget her, brukt lenger ned.
|
||||||
|
is_new_account = False
|
||||||
|
new_account_display_name = None
|
||||||
|
|
||||||
if secondary_owner_id is not None:
|
if secondary_owner_id is not None:
|
||||||
user_id = secondary_owner_id
|
user_id = secondary_owner_id
|
||||||
else:
|
else:
|
||||||
placeholder_name = email.split("@")[0]
|
placeholder_name = email.split("@")[0]
|
||||||
|
new_account_display_name = body.display_name or placeholder_name
|
||||||
user_id = await conn.fetchval(
|
user_id = await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app_user (email, display_name, preferred_locale)
|
INSERT INTO app_user (email, display_name, preferred_locale)
|
||||||
|
|
@ -269,15 +278,18 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
email,
|
email,
|
||||||
body.display_name or placeholder_name,
|
new_account_display_name,
|
||||||
token_row["locale"],
|
token_row["locale"],
|
||||||
)
|
)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
# En annen samtidig verifisering for samme e-post vant
|
# En annen samtidig verifisering for samme e-post vant
|
||||||
# innsettingen (eller brukeren fantes allerede) --
|
# innsettingen (eller brukeren fantes allerede) --
|
||||||
# preferred_locale røres IKKE her, kun ved førstegangs-
|
# preferred_locale røres IKKE her, kun ved førstegangs-
|
||||||
# opprettelse (se modul-docstring).
|
# opprettelse (se modul-docstring). Ikke en ny konto --
|
||||||
|
# ingen driftsvarsel.
|
||||||
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||||
|
else:
|
||||||
|
is_new_account = True
|
||||||
|
|
||||||
# Koble enhver player-rad (ADR-017) OG enhver ventende
|
# Koble enhver player-rad (ADR-017) OG enhver ventende
|
||||||
# organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
|
# organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
|
||||||
|
|
@ -286,6 +298,22 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
|
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
|
||||||
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
|
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
|
||||||
|
|
||||||
|
# Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
|
||||||
|
# BEVISST etter at connection-blokken er lukket, samme "e-post skal
|
||||||
|
# aldri sendes mens en tilkobling/transaksjon holdes åpen"-prinsipp som
|
||||||
|
# resten av appen. En feil her skal ALDRI endre selve innloggings-
|
||||||
|
# responsen -- fanget og logget, ikke kastet videre.
|
||||||
|
if is_new_account:
|
||||||
|
if settings.DEV_LOG_MAGIC_LINKS:
|
||||||
|
print(f"[DEV] Ny konto opprettet: {email} ({new_account_display_name})", flush=True)
|
||||||
|
elif settings.SMTP_CONFIGURED:
|
||||||
|
try:
|
||||||
|
await send_new_account_alert_email(
|
||||||
|
settings.NEW_ACCOUNT_ALERT_EMAIL, email, new_account_display_name
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
# user_id er en asyncpg UUID her (ingen ::text-cast i spørringene over) --
|
# user_id er en asyncpg UUID her (ingen ::text-cast i spørringene over) --
|
||||||
# create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på
|
# create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på
|
||||||
# en rå UUID. Fant under scratch-testing: str() FØR den sendes videre.
|
# en rå UUID. Fant under scratch-testing: str() FØR den sendes videre.
|
||||||
|
|
|
||||||
|
|
@ -768,6 +768,35 @@ async def update_bbb_hole(
|
||||||
return BBBHoleOut(**dict(row))
|
return BBBHoleOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb",
|
||||||
|
response_model=list[BBBHoleOut],
|
||||||
|
)
|
||||||
|
async def list_bbb_holes(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[BBBHoleOut]:
|
||||||
|
"""Rå bingo/bango/bongo-VALG per hull (ikke de utledede poengene) --
|
||||||
|
samme begrunnelse som round.py sin frittstående-motpart."""
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
|
||||||
|
bango_participant_id::text AS bango_participant_id,
|
||||||
|
bongo_participant_id::text AS bongo_participant_id
|
||||||
|
FROM tournament_round_bbb_hole WHERE tournament_round_id = $1
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
return [BBBHoleOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||||
"/participants/{round_participant_id}/holes/{hole_number}",
|
"/participants/{round_participant_id}/holes/{hole_number}",
|
||||||
|
|
|
||||||
|
|
@ -2343,10 +2343,20 @@ async def update_participant(
|
||||||
*values,
|
*values,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Side-tildeling endret (ADR-039) -- regn playing_handicap for begge
|
# Side-tildeling ELLER rating (HCP/utslag/kjønn) endret (ADR-039) --
|
||||||
# sider på nytt FØR raden under leses, slik at responsen reflekterer
|
# regn playing_handicap for begge sider på nytt FØR raden under
|
||||||
# den ferske verdien (billig no-op hvis fortsatt ikke komplett).
|
# leses, slik at responsen reflekterer den ferske verdien (billig
|
||||||
if "round_side_id" in updates and current["play_format"] in _TWO_SIDED_FORMATS:
|
# no-op hvis fortsatt ikke komplett). Reelt funn (2026-07-30,
|
||||||
|
# scratch-verifisering av High-low-high): en deltaker som FØRST ble
|
||||||
|
# tildelt en side og SIDEN fikk sin HCP satt/endret (f.eks. en gjest
|
||||||
|
# opprettet uten kjent HCP) fikk ALDRI regnet playing_handicap på
|
||||||
|
# nytt, siden det opprinnelige triggeret kun så på round_side_id --
|
||||||
|
# laget lignende resultatet permanent "ikke klart" til siden ble
|
||||||
|
# rørt på nytt. rating_changed dekker nå samme sak.
|
||||||
|
if (
|
||||||
|
("round_side_id" in updates or rating_changed)
|
||||||
|
and current["play_format"] in _TWO_SIDED_FORMATS
|
||||||
|
):
|
||||||
await _recompute_side_handicaps(conn, round_id, current["play_format"])
|
await _recompute_side_handicaps(conn, round_id, current["play_format"])
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -2672,6 +2682,29 @@ async def update_bbb_hole(
|
||||||
return BBBHoleOut(**dict(row))
|
return BBBHoleOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rounds/{round_id}/bbb", response_model=list[BBBHoleOut])
|
||||||
|
async def list_bbb_holes(
|
||||||
|
round_id: str,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[BBBHoleOut]:
|
||||||
|
"""Rå bingo/bango/bongo-VALG per hull -- ikke poengene selv (de er
|
||||||
|
utledet, se _build_format_result sin bbb-gren). Trengs for at
|
||||||
|
frontend-widgeten skal kunne vise HVEM som allerede er valgt på et
|
||||||
|
hull ved gjenlasting, ikke bare la brukeren gjette på nytt hver gang."""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
|
||||||
|
bango_participant_id::text AS bango_participant_id,
|
||||||
|
bongo_participant_id::text AS bongo_participant_id
|
||||||
|
FROM round_bbb_hole WHERE round_id = $1
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
return [BBBHoleOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
|
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
|
||||||
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),
|
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ type ApiTournamentInfo = {
|
||||||
join_code: string
|
join_code: string
|
||||||
format_type: string
|
format_type: string
|
||||||
scoring_method: string | null
|
scoring_method: string | null
|
||||||
|
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
||||||
|
bbb_sweep_bonus_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
|
|
@ -103,6 +105,23 @@ type ApiLeaderboardEntry = {
|
||||||
gross_total: number | null
|
gross_total: number | null
|
||||||
net_total: number | null
|
net_total: number | null
|
||||||
stableford_total: number | null
|
stableford_total: number | null
|
||||||
|
copenhagen_total: number | null
|
||||||
|
bbb_total: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiFlagResult = {
|
||||||
|
tournament_participant_id: string
|
||||||
|
player_name: string
|
||||||
|
holes_completed: number
|
||||||
|
ran_out: boolean
|
||||||
|
strokes_remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiBBBHole = {
|
||||||
|
hole_number: number
|
||||||
|
bingo_participant_id: string | null
|
||||||
|
bango_participant_id: string | null
|
||||||
|
bongo_participant_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiCourse = { id: string; name: string; source: string }
|
type ApiCourse = { id: string; name: string; source: string }
|
||||||
|
|
@ -119,6 +138,9 @@ const SCORING_METHOD_LABELS: Record<string, string> = {
|
||||||
stroke_gross: "Bruttoslagspill",
|
stroke_gross: "Bruttoslagspill",
|
||||||
stroke_net: "Nettoslagspill",
|
stroke_net: "Nettoslagspill",
|
||||||
stableford: "Stableford",
|
stableford: "Stableford",
|
||||||
|
copenhagen: "Københavner",
|
||||||
|
bingo_bango_bongo: "Bingo Bango Bongo",
|
||||||
|
flag: "Flaggturnering",
|
||||||
}
|
}
|
||||||
|
|
||||||
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
|
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
|
||||||
|
|
@ -231,6 +253,24 @@ export function IndividualTournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateBbbSweepBonus(enabled: boolean) {
|
||||||
|
if (!tournament) return
|
||||||
|
const previous = tournament
|
||||||
|
setTournament({ ...tournament, bbb_sweep_bonus_enabled: enabled })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ bbb_sweep_bonus_enabled: enabled }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
} catch {
|
||||||
|
setTournament(previous)
|
||||||
|
setError("Klarte ikke å endre bonuspoeng-innstillingen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addParticipant(playerId: string) {
|
async function addParticipant(playerId: string) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`${base}/participants`, {
|
const res = await fetch(`${base}/participants`, {
|
||||||
|
|
@ -418,6 +458,7 @@ export function IndividualTournamentDetail({
|
||||||
rounds={rounds}
|
rounds={rounds}
|
||||||
courses={courses}
|
courses={courses}
|
||||||
onUpdateScoringMethod={updateScoringMethod}
|
onUpdateScoringMethod={updateScoringMethod}
|
||||||
|
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||||
onRemoveParticipant={removeParticipant}
|
onRemoveParticipant={removeParticipant}
|
||||||
|
|
@ -428,7 +469,7 @@ export function IndividualTournamentDetail({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "score" && (
|
{tab === "score" && (
|
||||||
<ScoreTab base={base} rounds={rounds} onError={setError} />
|
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
|
||||||
)}
|
)}
|
||||||
{tab === "leaderboard" && (
|
{tab === "leaderboard" && (
|
||||||
<LeaderboardTab base={base} tournament={tournament} />
|
<LeaderboardTab base={base} tournament={tournament} />
|
||||||
|
|
@ -479,6 +520,7 @@ function SetupTab({
|
||||||
rounds,
|
rounds,
|
||||||
courses,
|
courses,
|
||||||
onUpdateScoringMethod,
|
onUpdateScoringMethod,
|
||||||
|
onUpdateBbbSweepBonus,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
onAddNewPlayer,
|
onAddNewPlayer,
|
||||||
onRemoveParticipant,
|
onRemoveParticipant,
|
||||||
|
|
@ -494,6 +536,7 @@ function SetupTab({
|
||||||
rounds: ApiRound[]
|
rounds: ApiRound[]
|
||||||
courses: ApiCourse[]
|
courses: ApiCourse[]
|
||||||
onUpdateScoringMethod: (method: string) => void
|
onUpdateScoringMethod: (method: string) => void
|
||||||
|
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
||||||
onAddParticipant: (playerId: string) => Promise<void>
|
onAddParticipant: (playerId: string) => Promise<void>
|
||||||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||||||
onRemoveParticipant: (id: string) => Promise<void>
|
onRemoveParticipant: (id: string) => Promise<void>
|
||||||
|
|
@ -539,6 +582,22 @@ function SetupTab({
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{tournament.scoring_method === "bingo_bango_bongo" && (
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tournament.bbb_sweep_bonus_enabled}
|
||||||
|
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Ekstra poeng til en spiller som vinner alle tre kategoriene på samme hull.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ParticipantsCard
|
<ParticipantsCard
|
||||||
|
|
@ -1281,10 +1340,12 @@ function AssignRoundParticipantControl({
|
||||||
function ScoreTab({
|
function ScoreTab({
|
||||||
base,
|
base,
|
||||||
rounds,
|
rounds,
|
||||||
|
scoringMethod,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
rounds: ApiRound[]
|
rounds: ApiRound[]
|
||||||
|
scoringMethod: string | null
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
||||||
|
|
@ -1292,6 +1353,9 @@ function ScoreTab({
|
||||||
const [participantId, setParticipantId] = useState("")
|
const [participantId, setParticipantId] = useState("")
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
const round = rounds.find((r) => r.id === roundId) ?? null
|
const round = rounds.find((r) => r.id === roundId) ?? null
|
||||||
|
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
||||||
|
// fetch) vet de skal hente på nytt.
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roundId) return
|
if (!roundId) return
|
||||||
|
|
@ -1334,6 +1398,7 @@ function ScoreTab({
|
||||||
}
|
}
|
||||||
const updated: ApiHole = await res.json()
|
const updated: ApiHole = await res.json()
|
||||||
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
|
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
|
||||||
|
setRefreshTick((n) => n + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rounds.length === 0) {
|
if (rounds.length === 0) {
|
||||||
|
|
@ -1396,12 +1461,194 @@ function ScoreTab({
|
||||||
{round && holes && (
|
{round && holes && (
|
||||||
<HoleGrid holes={holes} holeConfig={round.hole_config} onUpdate={updateHole} />
|
<HoleGrid holes={holes} holeConfig={round.hole_config} onUpdate={updateHole} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
|
||||||
|
uavhengig av gross_strokes-registreringen over. */}
|
||||||
|
{round && scoringMethod === "bingo_bango_bongo" && (
|
||||||
|
<BBBRoundPanel base={base} round={round} participants={roundParticipants} refreshTick={refreshTick} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke
|
||||||
|
akkumulerbart på tvers av runder, se LeaderboardTab). */}
|
||||||
|
{round && scoringMethod === "flag" && (
|
||||||
|
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) -----------------
|
||||||
|
|
||||||
|
function BBBCategoryRow({
|
||||||
|
label,
|
||||||
|
participants,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
participants: ApiRoundParticipant[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string | null) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{label}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{participants.map((p) => {
|
||||||
|
const active = selectedId === p.tournament_participant_id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.tournament_participant_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(active ? null : p.tournament_participant_id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"min-h-9 rounded-lg border px-2.5 text-xs font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.player_name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BBBRoundPanel({
|
||||||
|
base,
|
||||||
|
round,
|
||||||
|
participants,
|
||||||
|
refreshTick,
|
||||||
|
}: {
|
||||||
|
base: string
|
||||||
|
round: ApiRound
|
||||||
|
participants: ApiRoundParticipant[]
|
||||||
|
refreshTick: number
|
||||||
|
}) {
|
||||||
|
const numbers = holeNumbersFor(round.hole_config)
|
||||||
|
const [selectedHole, setSelectedHole] = useState(numbers[0] ?? 1)
|
||||||
|
const [bbbHoles, setBbbHoles] = useState<Record<number, ApiBBBHole>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
getJson<ApiBBBHole[]>(`${base}/rounds/${round.id}/bbb`).then((data) => {
|
||||||
|
if (!cancelled) setBbbHoles(Object.fromEntries((data ?? []).map((h) => [h.hole_number, h])))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [base, round.id, refreshTick])
|
||||||
|
|
||||||
|
async function set(field: "bingo_participant_id" | "bango_participant_id" | "bongo_participant_id", id: string | null) {
|
||||||
|
const res = await fetch(`${base}/rounds/${round.id}/bbb/${selectedHole}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ [field]: id }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const updated: ApiBBBHole = await res.json()
|
||||||
|
setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = bbbHoles[selectedHole] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo</span>
|
||||||
|
<select
|
||||||
|
value={selectedHole}
|
||||||
|
onChange={(e) => setSelectedHole(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-lg border border-border bg-background px-2 text-sm font-semibold text-foreground outline-none"
|
||||||
|
>
|
||||||
|
{numbers.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
Hull {n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bingo (først på green)"
|
||||||
|
participants={participants}
|
||||||
|
selectedId={selection?.bingo_participant_id ?? null}
|
||||||
|
onSelect={(id) => void set("bingo_participant_id", id)}
|
||||||
|
/>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bango (nærmest hull, når alle er på green)"
|
||||||
|
participants={participants}
|
||||||
|
selectedId={selection?.bango_participant_id ?? null}
|
||||||
|
onSelect={(id) => void set("bango_participant_id", id)}
|
||||||
|
/>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bongo (først i hull)"
|
||||||
|
participants={participants}
|
||||||
|
selectedId={selection?.bongo_participant_id ?? null}
|
||||||
|
onSelect={(id) => void set("bongo_participant_id", id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flaggturnering -- per-runde-resultat (2026-07-30) ----------------------
|
||||||
|
|
||||||
|
function FlagRoundPanel({
|
||||||
|
base,
|
||||||
|
round,
|
||||||
|
refreshTick,
|
||||||
|
}: {
|
||||||
|
base: string
|
||||||
|
round: ApiRound
|
||||||
|
refreshTick: number
|
||||||
|
}) {
|
||||||
|
const [results, setResults] = useState<ApiFlagResult[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
getJson<ApiFlagResult[]>(`${base}/rounds/${round.id}/flag-result`).then((data) => {
|
||||||
|
if (!cancelled) setResults(data)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [base, round.id, refreshTick])
|
||||||
|
|
||||||
|
if (!results) return null
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Venter på at handicap beregnes for minst én spiller.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<span className="text-sm font-bold text-foreground">Flaggturnering</span>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{results.map((r) => (
|
||||||
|
<li key={r.tournament_participant_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{r.player_name}</span>
|
||||||
|
<span className="flex flex-col items-end">
|
||||||
|
<span className="text-sm font-extrabold tabular-nums text-foreground">{r.holes_completed} hull</span>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{r.ran_out ? "gikk tom for slag" : `${r.strokes_remaining} slag igjen`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
||||||
|
|
||||||
function classify(diff: number): ScoreClass {
|
function classify(diff: number): ScoreClass {
|
||||||
|
|
@ -1577,6 +1824,8 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
function valueFor(entry: ApiLeaderboardEntry): { label: string; value: number | null } {
|
function valueFor(entry: ApiLeaderboardEntry): { label: string; value: number | null } {
|
||||||
if (method === "stableford") return { label: "poeng", value: entry.stableford_total }
|
if (method === "stableford") return { label: "poeng", value: entry.stableford_total }
|
||||||
if (method === "stroke_net") return { label: "netto", value: entry.net_total }
|
if (method === "stroke_net") return { label: "netto", value: entry.net_total }
|
||||||
|
if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total }
|
||||||
|
if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_total }
|
||||||
return { label: "slag", value: entry.gross_total }
|
return { label: "slag", value: entry.gross_total }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1604,6 +1853,17 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flaggturnering er en PER-RUNDE-konkurranse ("hvem kommer lengst DENNE
|
||||||
|
// runden"), ikke et akkumulerbart tall på tvers av flere runder -- se
|
||||||
|
// egen resultatvisning per runde under "Scorekort" i stedet.
|
||||||
|
if (method === "flag") {
|
||||||
|
return (
|
||||||
|
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
||||||
|
Flaggturnering vises per runde -- se resultatet under «Scorekort» for hver runde.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm font-medium text-muted-foreground text-pretty">Ingen deltakere ennå.</p>
|
<p className="text-sm font-medium text-muted-foreground text-pretty">Ingen deltakere ennå.</p>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,18 @@ type PlayFormat =
|
||||||
| "greensome"
|
| "greensome"
|
||||||
| "scramble_2"
|
| "scramble_2"
|
||||||
| "scramble_4"
|
| "scramble_4"
|
||||||
|
// Åtte nye formater (2026-07-30, se ADR/plan for full begrunnelse).
|
||||||
|
// Chapman/high_low_high er to-sidede (sider settes opp i selve runden,
|
||||||
|
// som match/fourball/foursome). Shamble/money_ball er flatt felt (ETT
|
||||||
|
// lag = hele rundens deltakersett, INGEN round_side). Copenhagen/bbb/
|
||||||
|
// flag er også flatt felt, individuelt.
|
||||||
|
| "chapman"
|
||||||
|
| "copenhagen"
|
||||||
|
| "bbb"
|
||||||
|
| "flag"
|
||||||
|
| "shamble"
|
||||||
|
| "money_ball"
|
||||||
|
| "high_low_high"
|
||||||
type SkinsScoring = "net" | "gross"
|
type SkinsScoring = "net" | "gross"
|
||||||
type SkinsTieHandling = "carry" | "split"
|
type SkinsTieHandling = "carry" | "split"
|
||||||
|
|
||||||
|
|
@ -120,6 +132,13 @@ const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[]
|
||||||
{ value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
|
{ value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
|
||||||
{ value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
|
{ value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
|
||||||
{ value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
|
{ value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
|
||||||
|
{ value: "chapman", label: "Chapman", desc: "To mot to, bytter utslag, velger så én ball -- som foursome fra andre slag." },
|
||||||
|
{ value: "copenhagen", label: "Københavner", desc: "Nøyaktig 3 spillere -- 6 poeng deles per hull etter rangering." },
|
||||||
|
{ value: "bbb", label: "Bingo Bango Bongo", desc: "Poeng for først på green, nærmest hull, først i hull." },
|
||||||
|
{ value: "flag", label: "Flaggturnering", desc: "Fast slagbudsjett (par + HCP) -- flagget plantes der slagene tar slutt." },
|
||||||
|
{ value: "shamble", label: "Shamble", desc: "2-4 spillere, alle slår ut, velger beste -- spiller så egen ball inn." },
|
||||||
|
{ value: "money_ball", label: "Money Ball", desc: "4 spillere, én roterer som \"money ball\" per hull." },
|
||||||
|
{ value: "high_low_high", label: "High-low-high", desc: "To lag à to -- høy mot høy, lav mot lav, per hull." },
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- API-typer ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
@ -262,6 +281,8 @@ export function NewRound() {
|
||||||
excludeFromHandicap: boolean
|
excludeFromHandicap: boolean
|
||||||
skinsScoring: SkinsScoring | null
|
skinsScoring: SkinsScoring | null
|
||||||
skinsTieHandling: SkinsTieHandling | null
|
skinsTieHandling: SkinsTieHandling | null
|
||||||
|
bbbSweepBonusEnabled: boolean
|
||||||
|
shambleBestN: number | null
|
||||||
visibilityMode: VisibilityMode
|
visibilityMode: VisibilityMode
|
||||||
visibleCategories: string[]
|
visibleCategories: string[]
|
||||||
}): Promise<{ id: string }> {
|
}): Promise<{ id: string }> {
|
||||||
|
|
@ -278,6 +299,8 @@ export function NewRound() {
|
||||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||||
skins_scoring: payload.skinsScoring,
|
skins_scoring: payload.skinsScoring,
|
||||||
skins_tie_handling: payload.skinsTieHandling,
|
skins_tie_handling: payload.skinsTieHandling,
|
||||||
|
bbb_sweep_bonus_enabled: payload.bbbSweepBonusEnabled,
|
||||||
|
shamble_best_n: payload.shambleBestN,
|
||||||
visibility_mode: payload.visibilityMode,
|
visibility_mode: payload.visibilityMode,
|
||||||
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
|
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
|
||||||
flight_group_id: flightGroupId || undefined,
|
flight_group_id: flightGroupId || undefined,
|
||||||
|
|
@ -1194,6 +1217,8 @@ function ConfirmStep({
|
||||||
excludeFromHandicap: boolean
|
excludeFromHandicap: boolean
|
||||||
skinsScoring: SkinsScoring | null
|
skinsScoring: SkinsScoring | null
|
||||||
skinsTieHandling: SkinsTieHandling | null
|
skinsTieHandling: SkinsTieHandling | null
|
||||||
|
bbbSweepBonusEnabled: boolean
|
||||||
|
shambleBestN: number | null
|
||||||
visibilityMode: VisibilityMode
|
visibilityMode: VisibilityMode
|
||||||
visibleCategories: string[]
|
visibleCategories: string[]
|
||||||
}) => Promise<{ id: string }>
|
}) => Promise<{ id: string }>
|
||||||
|
|
@ -1220,6 +1245,9 @@ function ConfirmStep({
|
||||||
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
||||||
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
||||||
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
|
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
|
||||||
|
// KUN meningsfullt for play_format="bbb"/"shamble" -- se ADR/plan.
|
||||||
|
const [bbbSweepBonusEnabled, setBbbSweepBonusEnabled] = useState(false)
|
||||||
|
const [shambleBestN, setShambleBestN] = useState(1)
|
||||||
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
|
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
|
||||||
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
|
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
|
||||||
// Alle forhåndsvalgt (2026-07-29, presisert av bruker: "man må heller
|
// Alle forhåndsvalgt (2026-07-29, presisert av bruker: "man må heller
|
||||||
|
|
@ -1239,10 +1267,17 @@ function ConfirmStep({
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTee = compatibleTees.find((t) => t.id === teeId)
|
const selectedTee = compatibleTees.find((t) => t.id === teeId)
|
||||||
// ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble
|
// ADR-039 Beslutning A -- to-sidede formater krever sider, opprettet
|
||||||
// krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må
|
// ETTERPÅ inne i selve runden (deltakerne må finnes først). Vises som en
|
||||||
// finnes først). Vises som en forklarende note her, ikke et skjema.
|
// forklarende note her, ikke et skjema. Shamble/money_ball/copenhagen/
|
||||||
const needsSidesSetup = playFormat !== "stroke" && playFormat !== "stableford" && playFormat !== "skins"
|
// bbb/flag er BEVISST flatt felt (ETT lag = hele rundens deltakersett,
|
||||||
|
// eller individuelt) -- ingen round_side her (se ADR-039 for shamble/
|
||||||
|
// money_ball, plan for copenhagen/bbb/flag).
|
||||||
|
const TWO_SIDED = new Set<PlayFormat>([
|
||||||
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
])
|
||||||
|
const needsSidesSetup = TWO_SIDED.has(playFormat)
|
||||||
|
const needsLineupSetup = playFormat === "money_ball"
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
if (!selectedTee) return
|
if (!selectedTee) return
|
||||||
|
|
@ -1265,6 +1300,8 @@ function ConfirmStep({
|
||||||
excludeFromHandicap,
|
excludeFromHandicap,
|
||||||
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
||||||
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
||||||
|
bbbSweepBonusEnabled: playFormat === "bbb" ? bbbSweepBonusEnabled : false,
|
||||||
|
shambleBestN: playFormat === "shamble" ? shambleBestN : null,
|
||||||
visibilityMode,
|
visibilityMode,
|
||||||
visibleCategories,
|
visibleCategories,
|
||||||
})
|
})
|
||||||
|
|
@ -1527,6 +1564,70 @@ function ConfirmStep({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{playFormat === "bbb" && (
|
||||||
|
<label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bbbSweepBonusEnabled}
|
||||||
|
onChange={(e) => setBbbSweepBonusEnabled(e.target.checked)}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-base font-bold text-foreground">Bonuspoeng ved "sveip"</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Ekstra poeng til en spiller som vinner alle tre kategoriene (bingo, bango og bongo)
|
||||||
|
på samme hull.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playFormat === "shamble" && (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card px-5 py-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Beste N av laget teller per hull
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShambleBestN(n)}
|
||||||
|
aria-pressed={shambleBestN === n}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
|
||||||
|
shambleBestN === n
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
2-4 spillere på laget, alle slår ut og velger beste utslag -- deretter spiller alle
|
||||||
|
egen ball inn. Kan ikke endres etter at runden er opprettet. Legg til medspillere etter
|
||||||
|
opprettelse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsLineupSetup && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nøyaktig 4 spillere -- du setter opp hvem som er "money ball" på hvilket hull inne i
|
||||||
|
runden, etter at medspillerne er lagt til.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playFormat === "copenhagen" && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nøyaktig 3 spillere -- legg til medspillere etter at runden er opprettet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{needsSidesSetup && (
|
{needsSidesSetup && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er
|
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ type Player = {
|
||||||
// og den format-relative match-play-enheten (ikke samme som courseHandicap).
|
// og den format-relative match-play-enheten (ikke samme som courseHandicap).
|
||||||
roundSideId: string | null
|
roundSideId: string | null
|
||||||
playingHandicap: number | null
|
playingHandicap: number | null
|
||||||
|
// Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
|
||||||
|
lineupOrder: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type HoleStat = {
|
type HoleStat = {
|
||||||
|
|
@ -186,6 +188,9 @@ type ApiParticipant = {
|
||||||
// ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som
|
// ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som
|
||||||
// course_handicap_snapshot (den absolutte WHS-verdien).
|
// course_handicap_snapshot (den absolutte WHS-verdien).
|
||||||
playing_handicap: number | null
|
playing_handicap: number | null
|
||||||
|
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
|
||||||
|
// null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
|
||||||
|
lineup_order: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiSide = {
|
type ApiSide = {
|
||||||
|
|
@ -252,8 +257,16 @@ const VISIBILITY_CATEGORY_OPTIONS = [
|
||||||
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
||||||
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
||||||
// server-side -- disse styrer bare presentasjon.
|
// server-side -- disse styrer bare presentasjon.
|
||||||
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
|
// Åtte nye formater (2026-07-30). Chapman er to-sidet+delt-ball (som
|
||||||
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4"])
|
// foursome). High-low-high er to-sidet+individuell-ball (som fourball).
|
||||||
|
// Copenhagen/BBB/Flag/Shamble/Money Ball er ALDRI to-sidet (flatt felt,
|
||||||
|
// se ADR/plan) -- faller derfor gjennom til vanlig individuell-ball
|
||||||
|
// scorekort/veiviser (samme som stroke/stableford/skins), med egne
|
||||||
|
// tilleggsvisninger i FormatResultPanel.
|
||||||
|
const TWO_SIDED_FORMATS = new Set([
|
||||||
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
])
|
||||||
|
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
|
||||||
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||||||
match: 1,
|
match: 1,
|
||||||
fourball: 2,
|
fourball: 2,
|
||||||
|
|
@ -261,6 +274,8 @@ const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||||||
greensome: 2,
|
greensome: 2,
|
||||||
scramble_2: 2,
|
scramble_2: 2,
|
||||||
scramble_4: 4,
|
scramble_4: 4,
|
||||||
|
chapman: 2,
|
||||||
|
high_low_high: 2,
|
||||||
}
|
}
|
||||||
const FORMAT_LABELS: Record<string, string> = {
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
stroke: "Slagspill",
|
stroke: "Slagspill",
|
||||||
|
|
@ -272,7 +287,17 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
greensome: "Greensome",
|
greensome: "Greensome",
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
|
chapman: "Chapman",
|
||||||
|
copenhagen: "Københavner",
|
||||||
|
bbb: "Bingo Bango Bongo",
|
||||||
|
flag: "Flaggturnering",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
|
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
|
||||||
|
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
|
||||||
|
const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "money_ball"])
|
||||||
|
|
||||||
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
||||||
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
||||||
|
|
@ -507,6 +532,41 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
}, [roundId, round?.play_format, formatResultRefreshTick])
|
}, [roundId, round?.play_format, formatResultRefreshTick])
|
||||||
|
|
||||||
|
// Bingo Bango Bongo (2026-07-30) -- rå bingo/bango/bongo-VALG per hull
|
||||||
|
// (ikke poengene, som ligger i formatResult over). Egen henting siden
|
||||||
|
// dette er en separat tabell (round_bbb_hole), ikke del av
|
||||||
|
// RoundFormatResultOut sin generiske form.
|
||||||
|
const [bbbHoles, setBbbHoles] = useState<Record<number, ApiBBBHole>>({})
|
||||||
|
useEffect(() => {
|
||||||
|
if (!round || round.play_format !== "bbb") {
|
||||||
|
setBbbHoles({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/rounds/${roundId}/bbb`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiBBBHole[]) => {
|
||||||
|
if (!cancelled) setBbbHoles(Object.fromEntries(data.map((h) => [h.hole_number, h])))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, round?.play_format, formatResultRefreshTick])
|
||||||
|
|
||||||
|
async function patchBbbHole(holeNumber: number, body: Record<string, string | null>) {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/bbb/${holeNumber}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
const updated: ApiBBBHole = await res.json()
|
||||||
|
setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated }))
|
||||||
|
setFormatResultRefreshTick((n) => n + 1)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch("/auth/me", { credentials: "include" })
|
fetch("/auth/me", { credentials: "include" })
|
||||||
|
|
@ -739,6 +799,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
excludeFromHandicap: p.exclude_from_handicap,
|
excludeFromHandicap: p.exclude_from_handicap,
|
||||||
roundSideId: p.round_side_id,
|
roundSideId: p.round_side_id,
|
||||||
playingHandicap: p.playing_handicap,
|
playingHandicap: p.playing_handicap,
|
||||||
|
lineupOrder: p.lineup_order,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
||||||
|
|
@ -1198,6 +1259,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
(ADR-039) -- avledet ved lesing, aldri lagret. */}
|
(ADR-039) -- avledet ved lesing, aldri lagret. */}
|
||||||
<FormatResultPanel round={round} players={players} result={formatResult} />
|
<FormatResultPanel round={round} players={players} result={formatResult} />
|
||||||
|
|
||||||
|
{/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer
|
||||||
|
(front9/back9/overall) av det samme to-sidede formatet,
|
||||||
|
kun for 18-hulls runder. Ren tilleggsvisning, ikke et eget
|
||||||
|
play_format. */}
|
||||||
|
{TWO_SIDED_FORMATS.has(round.play_format) && round.holes_planned === 18 && (
|
||||||
|
<NassauPanel roundId={roundId} formatResultRefreshTick={formatResultRefreshTick} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
|
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
|
||||||
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
|
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
|
||||||
Eieren tar ansvar for å sette opp ALLE flightene i én
|
Eieren tar ansvar for å sette opp ALLE flightene i én
|
||||||
|
|
@ -1313,6 +1382,28 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
Samme komponent, ikke duplisert logikk. */}
|
Samme komponent, ikke duplisert logikk. */}
|
||||||
<FormatResultPanel round={round} players={players} result={formatResult} />
|
<FormatResultPanel round={round} players={players} result={formatResult} />
|
||||||
|
|
||||||
|
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon
|
||||||
|
(ikke GPS/live-sporet), separat fra selve slag-/putt-
|
||||||
|
registreringen (som fortsatt skjer via vanlig ScoringWizard,
|
||||||
|
for stat_level/GIR/HCP-formål -- BBB-poengene er UAVHENGIGE
|
||||||
|
av gross_strokes). */}
|
||||||
|
{round.play_format === "bbb" && !readOnly && (
|
||||||
|
<BBBHolePicker
|
||||||
|
players={players}
|
||||||
|
holeNumber={activeHole}
|
||||||
|
selection={bbbHoles[activeHole] ?? null}
|
||||||
|
onSet={patchBbbHole}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Money Ball (2026-07-30) -- rekkefølgen (0-3) må være tildelt for
|
||||||
|
alle fire FØR scoring/fullføring gir mening (avgjør hvem som er
|
||||||
|
"money ball" på hvilket hull). Vises kun til rekkefølgen er
|
||||||
|
komplett, deretter automatisk skjult. */}
|
||||||
|
{round.play_format === "money_ball" && isOwnerViewer && !readOnly && (
|
||||||
|
<MoneyBallLineupPanel players={players} onPatchParticipant={patchParticipant} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Auto-prompt "Fullfør runde" (2026-07-30) -- kun synlig når alle
|
{/* Auto-prompt "Fullfør runde" (2026-07-30) -- kun synlig når alle
|
||||||
hull faktisk er ført for alle, forsvinner selv idet runden er
|
hull faktisk er ført for alle, forsvinner selv idet runden er
|
||||||
fullført (completed flipper til true). */}
|
fullført (completed flipper til true). */}
|
||||||
|
|
@ -2430,6 +2521,8 @@ type ApiFormatHoleEntry = {
|
||||||
net: number | null
|
net: number | null
|
||||||
strokes_received: number | null
|
strokes_received: number | null
|
||||||
counted: boolean | null
|
counted: boolean | null
|
||||||
|
// Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE hullet.
|
||||||
|
points: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiFormatHoleOut = {
|
type ApiFormatHoleOut = {
|
||||||
|
|
@ -2442,6 +2535,20 @@ type ApiFormatHoleOut = {
|
||||||
carried: boolean | null
|
carried: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiFlagResult = {
|
||||||
|
holes_completed: number
|
||||||
|
ran_out: boolean
|
||||||
|
strokes_remaining: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiBBBHole = {
|
||||||
|
hole_number: number
|
||||||
|
bingo_participant_id: string | null
|
||||||
|
bango_participant_id: string | null
|
||||||
|
bongo_participant_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type ApiFormatResult = {
|
type ApiFormatResult = {
|
||||||
play_format: string
|
play_format: string
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
|
@ -2453,6 +2560,15 @@ type ApiFormatResult = {
|
||||||
match_status_text: string | null
|
match_status_text: string | null
|
||||||
hole_results: string[] | null
|
hole_results: string[] | null
|
||||||
skins_won: Record<string, number> | null
|
skins_won: Record<string, number> | null
|
||||||
|
// Åtte nye formater (2026-07-30) -- se app/routers/rounds.py sin
|
||||||
|
// RoundFormatResultOut for full kontrakt.
|
||||||
|
copenhagen_points: Record<string, number> | null
|
||||||
|
bbb_points: Record<string, number> | null
|
||||||
|
flag_results: Record<string, ApiFlagResult> | null
|
||||||
|
shamble_team_score: number | null
|
||||||
|
money_ball_team_score: number | null
|
||||||
|
hlh_points_a: number | null
|
||||||
|
hlh_points_b: number | null
|
||||||
holes: ApiFormatHoleOut[] | null
|
holes: ApiFormatHoleOut[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2769,7 +2885,164 @@ function FormatResultPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To-sidede formater (match/fourball/foursome/greensome/scramble).
|
// Københavner (2026-07-30) -- flatt felt, poengtabell (6 poeng deles
|
||||||
|
// per hull mellom nøyaktig 3 spillere -- flest poeng totalt vinner).
|
||||||
|
if (round.play_format === "copenhagen") {
|
||||||
|
const points = result.copenhagen_points ?? {}
|
||||||
|
const rows = players
|
||||||
|
.map((p) => ({ player: p, points: points[p.id] ?? 0 }))
|
||||||
|
.sort((a, b) => b.points - a.points)
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Københavner -- poengtavle</span>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{rows.map(({ player, points: pts }) => (
|
||||||
|
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||||||
|
<span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bingo Bango Bongo (2026-07-30) -- flatt felt, poengtabell (registreres
|
||||||
|
// per hull via egen BBBHolePicker, se lenger ned i Score-fanen).
|
||||||
|
if (round.play_format === "bbb") {
|
||||||
|
const points = result.bbb_points ?? {}
|
||||||
|
const rows = players
|
||||||
|
.map((p) => ({ player: p, points: points[p.id] ?? 0 }))
|
||||||
|
.sort((a, b) => b.points - a.points)
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- poengtavle</span>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{rows.map(({ player, points: pts }) => (
|
||||||
|
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||||||
|
<span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flaggturnering (2026-07-30) -- flatt felt, individuelt slagbudsjett.
|
||||||
|
// Flest fullførte hull vinner (ran_out=true, gikk tom for slag).
|
||||||
|
if (round.play_format === "flag") {
|
||||||
|
const results = result.flag_results ?? {}
|
||||||
|
const rows = Object.entries(results)
|
||||||
|
.map(([id, r]) => ({ id, ...r }))
|
||||||
|
.sort((a, b) => b.holes_completed - a.holes_completed)
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Flaggturnering</span>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Venter på at handicap beregnes for minst én spiller.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{r.label}</span>
|
||||||
|
<span className="flex flex-col items-end">
|
||||||
|
<span className="text-base font-extrabold tabular-nums text-foreground">
|
||||||
|
{r.holes_completed} hull
|
||||||
|
</span>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{r.ran_out
|
||||||
|
? "gikk tom for slag"
|
||||||
|
: `${r.strokes_remaining} slag igjen`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shamble/Money Ball (2026-07-30) -- flatt felt, ETT lag = hele rundens
|
||||||
|
// deltakersett (ingen motstander innad -- flere lag kobles via
|
||||||
|
// flight_group_id-leaderboardet). Viser kun lagets samlede sum.
|
||||||
|
if (round.play_format === "shamble" || round.play_format === "money_ball") {
|
||||||
|
const teamScore = round.play_format === "shamble" ? result.shamble_team_score : result.money_ball_team_score
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-5 text-center">
|
||||||
|
<span className="text-sm font-bold text-foreground">
|
||||||
|
{FORMAT_LABELS[round.play_format]} -- lagets samlede score
|
||||||
|
</span>
|
||||||
|
<span className="text-4xl font-extrabold tabular-nums text-foreground">{teamScore ?? "–"}</span>
|
||||||
|
{teamScore === null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Venter på at alle spillerne registrerer på minst ett hull.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-low-high (2026-07-30) -- literal poengsum (IKKE match-play
|
||||||
|
// "X UP" -- poeng fordeles 0-2 per hull via to uavhengige duelløer,
|
||||||
|
// se handicap_engine.py sin high_low_high_points_for_hole). Samme
|
||||||
|
// territorium-bar-visuelle språk som resten av to-sidede formater,
|
||||||
|
// men banneret viser "{poeng A}-{poeng B}" i stedet for en "X UP"-tekst.
|
||||||
|
if (round.play_format === "high_low_high") {
|
||||||
|
if (!result.ready) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
Venter på at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const pa = result.hlh_points_a ?? 0
|
||||||
|
const pb = result.hlh_points_b ?? 0
|
||||||
|
const holesPlayed = result.match_holes_played ?? 0
|
||||||
|
const identityA = sideIdentity(round, players, "a")
|
||||||
|
const identityB = sideIdentity(round, players, "b")
|
||||||
|
const tone: "a" | "b" | "neutral" = pa > pb ? "a" : pb > pa ? "b" : "neutral"
|
||||||
|
const widthA = pa + pb > 0 ? (pa / (pa + pb)) * 100 : 50
|
||||||
|
const widthB = 100 - widthA
|
||||||
|
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
|
||||||
|
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">High-low-high</span>
|
||||||
|
<div
|
||||||
|
className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
|
||||||
|
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
|
||||||
|
>
|
||||||
|
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
|
||||||
|
<LeadZone identity={identityA} side="a" dominant={tone === "a"} />
|
||||||
|
</div>
|
||||||
|
<div className="flex h-full items-center justify-center px-2">
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
|
||||||
|
{holesPlayed > 0 ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
|
||||||
|
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pa}-{pb}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
|
||||||
|
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
|
||||||
if (!result.ready) {
|
if (!result.ready) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
|
@ -2852,6 +3125,211 @@ function FormatResultPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data --
|
||||||
|
// tre parallelle vinduer av et EKSISTERENDE to-sidet format, ingen egen
|
||||||
|
// lagring (se app/routers/rounds.py sin get_round_nassau_result).
|
||||||
|
|
||||||
|
type ApiNassauWindow = {
|
||||||
|
lead: number
|
||||||
|
holes_played: number
|
||||||
|
holes_remaining: number
|
||||||
|
is_closed: boolean
|
||||||
|
is_dormie: boolean
|
||||||
|
status_text: string
|
||||||
|
}
|
||||||
|
type ApiNassauResult = {
|
||||||
|
ready: boolean
|
||||||
|
front9: ApiNassauWindow | null
|
||||||
|
back9: ApiNassauWindow | null
|
||||||
|
overall: ApiNassauWindow | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function NassauWindowCard({ label, window }: { label: string; window: ApiNassauWindow | null }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-background px-3 py-3">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
{window && window.holes_played > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-extrabold tabular-nums text-foreground">{window.status_text}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{window.holes_played} hull spilt</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">–</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) {
|
||||||
|
const [result, setResult] = useState<ApiNassauResult | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/rounds/${roundId}/format-result/nassau`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiNassauResult | null) => {
|
||||||
|
if (!cancelled) setResult(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, formatResultRefreshTick])
|
||||||
|
|
||||||
|
if (!result?.ready) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Nassau</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NassauWindowCard label="Ut (1-9)" window={result.front9} />
|
||||||
|
<NassauWindowCard label="Inn (10-18)" window={result.back9} />
|
||||||
|
<NassauWindowCard label="Totalt" window={result.overall} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) -----------------
|
||||||
|
// Tre uavhengige kategorier (først på green/nærmest hull/først i hull),
|
||||||
|
// manuelt registrert av den som fører score -- ikke GPS/live-sporet. Én
|
||||||
|
// spiller kan vinne flere/alle kategorier på samme hull (sistnevnte utløser
|
||||||
|
// evt. sweep-bonusen, se compute_bbb). Klikk på en allerede valgt spiller
|
||||||
|
// fjerner valget (samme "toggle av/på"-mønster som resten av appen).
|
||||||
|
|
||||||
|
function BBBCategoryRow({
|
||||||
|
label,
|
||||||
|
players,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
players: Player[]
|
||||||
|
selectedId: string | null
|
||||||
|
onSelect: (id: string | null) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{players.map((p) => {
|
||||||
|
const active = selectedId === p.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(active ? null : p.id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"min-h-11 rounded-xl border px-3 text-sm font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BBBHolePicker({
|
||||||
|
players,
|
||||||
|
holeNumber,
|
||||||
|
selection,
|
||||||
|
onSet,
|
||||||
|
}: {
|
||||||
|
players: Player[]
|
||||||
|
holeNumber: number
|
||||||
|
selection: ApiBBBHole | null
|
||||||
|
onSet: (holeNumber: number, body: Record<string, string | null>) => Promise<void>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- hull {holeNumber}</span>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bingo (først på green)"
|
||||||
|
players={players}
|
||||||
|
selectedId={selection?.bingo_participant_id ?? null}
|
||||||
|
onSelect={(id) => void onSet(holeNumber, { bingo_participant_id: id })}
|
||||||
|
/>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bango (nærmest hull, når alle er på green)"
|
||||||
|
players={players}
|
||||||
|
selectedId={selection?.bango_participant_id ?? null}
|
||||||
|
onSelect={(id) => void onSet(holeNumber, { bango_participant_id: id })}
|
||||||
|
/>
|
||||||
|
<BBBCategoryRow
|
||||||
|
label="Bongo (først i hull)"
|
||||||
|
players={players}
|
||||||
|
selectedId={selection?.bongo_participant_id ?? null}
|
||||||
|
onSelect={(id) => void onSet(holeNumber, { bongo_participant_id: id })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Money Ball -- lineup_order-tildeling (2026-07-30) ----------------------
|
||||||
|
// Fast rotasjonsrekkefølge (0-3), avgjør hvem som er "money ball" på hvilket
|
||||||
|
// hull. Gjenbruker eksisterende patchParticipant (samme generiske PATCH som
|
||||||
|
// resten av spiller-redigeringen) -- ingen egen fetch-logikk her.
|
||||||
|
|
||||||
|
function MoneyBallLineupPanel({
|
||||||
|
players,
|
||||||
|
onPatchParticipant,
|
||||||
|
}: {
|
||||||
|
players: Player[]
|
||||||
|
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const allAssigned = players.length === 4 && players.every((p) => p.lineupOrder !== null)
|
||||||
|
|
||||||
|
async function assign(playerId: string, order: number) {
|
||||||
|
setError(null)
|
||||||
|
const res = await onPatchParticipant(playerId, { lineup_order: order })
|
||||||
|
if (!res.ok) setError(res.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allAssigned) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Money Ball -- rekkefølge</span>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{players.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between gap-3 rounded-xl border border-border px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{p.name}</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[0, 1, 2, 3].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void assign(p.id, n)}
|
||||||
|
aria-pressed={p.lineupOrder === n}
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 items-center justify-center rounded-lg border text-sm font-bold transition-colors",
|
||||||
|
p.lineupOrder === n
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Player tabs -----------------------------------------------------------
|
// --- Player tabs -----------------------------------------------------------
|
||||||
|
|
||||||
// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
|
// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
|
||||||
|
|
@ -4362,7 +4840,16 @@ function ScorecardGrid({
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{showMatchColors && (
|
{/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
|
||||||
|
fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
|
||||||
|
lead-poeng per vunnet hull som resten av to-sidede formater
|
||||||
|
(denne raden ville da vist en misvisende match-play-tekst
|
||||||
|
basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
|
||||||
|
vises i stedet i FormatResultPanel over selve gridet --
|
||||||
|
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
|
||||||
|
vises uendret. Formatet er uansett aldri delt-ball, så denne
|
||||||
|
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
|
||||||
|
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
|
||||||
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
||||||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||||||
Stilling
|
Stilling
|
||||||
|
|
@ -4756,7 +5243,16 @@ function SideScorecardGrid({
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{showMatchColors && (
|
{/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
|
||||||
|
fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
|
||||||
|
lead-poeng per vunnet hull som resten av to-sidede formater
|
||||||
|
(denne raden ville da vist en misvisende match-play-tekst
|
||||||
|
basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
|
||||||
|
vises i stedet i FormatResultPanel over selve gridet --
|
||||||
|
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
|
||||||
|
vises uendret. Formatet er uansett aldri delt-ball, så denne
|
||||||
|
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
|
||||||
|
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
|
||||||
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
<tr className="border-b border-border bg-muted/30 last:border-b-0">
|
||||||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||||||
Stilling
|
Stilling
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,9 @@ type ApiRoundHeader = {
|
||||||
// noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én
|
// noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én
|
||||||
// score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville
|
// score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville
|
||||||
// derfor vist en tom/meningsløs liste for dem uansett.
|
// derfor vist en tom/meningsløs liste for dem uansett.
|
||||||
const TWO_SIDED_FORMATS = ["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"]
|
const TWO_SIDED_FORMATS = [
|
||||||
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
]
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ type ApiHole = {
|
||||||
strokes_received: number | null
|
strokes_received: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
|
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
||||||
const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS]
|
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS]
|
||||||
const FORMAT_LABELS: Record<string, string> = {
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
stroke: "Slagspill",
|
stroke: "Slagspill",
|
||||||
stableford: "Stableford",
|
stableford: "Stableford",
|
||||||
|
|
@ -85,6 +85,13 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
greensome: "Greensome",
|
greensome: "Greensome",
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
|
chapman: "Chapman",
|
||||||
|
copenhagen: "Københavner",
|
||||||
|
bbb: "Bingo Bango Bongo",
|
||||||
|
flag: "Flaggturnering",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
|
||||||
|
|
@ -400,12 +400,13 @@ type ApiSideHole = {
|
||||||
// slag-for-slag-registrering) er strukturelt umulig å vise for disse
|
// slag-for-slag-registrering) er strukturelt umulig å vise for disse
|
||||||
// formatene. Vist som en forklarende melding i stedet for en stille tom/
|
// formatene. Vist som en forklarende melding i stedet for en stille tom/
|
||||||
// misvisende side (2026-07-28, funnet ved simulering av alle spillformater).
|
// misvisende side (2026-07-28, funnet ved simulering av alle spillformater).
|
||||||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
|
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
||||||
const FORMAT_LABELS: Record<string, string> = {
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
foursome: "Foursome",
|
foursome: "Foursome",
|
||||||
greensome: "Greensome",
|
greensome: "Greensome",
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
|
chapman: "Chapman",
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHole = {
|
type ApiHole = {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,18 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py)
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py)
|
||||||
|
|
||||||
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
// Fire nye org-lagturnering-formater (2026-07-30) -- se tournament-program.tsx.
|
||||||
|
type Format =
|
||||||
|
| "foursome"
|
||||||
|
| "greensome"
|
||||||
|
| "scramble_2"
|
||||||
|
| "scramble_4"
|
||||||
|
| "fourball"
|
||||||
|
| "singles"
|
||||||
|
| "chapman"
|
||||||
|
| "shamble"
|
||||||
|
| "money_ball"
|
||||||
|
| "high_low_high"
|
||||||
type HoleConfig = "full_18" | "front_9" | "back_9"
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||||
type ScoringMode = "stroke" | "hole_result"
|
type ScoringMode = "stroke" | "hole_result"
|
||||||
|
|
||||||
|
|
@ -49,6 +60,8 @@ type ApiParticipant = {
|
||||||
player_name: string
|
player_name: string
|
||||||
tee_id: string
|
tee_id: string
|
||||||
tee_name: string
|
tee_name: string
|
||||||
|
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3).
|
||||||
|
lineup_order: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiMatch = {
|
type ApiMatch = {
|
||||||
|
|
@ -73,16 +86,30 @@ const FORMAT_LABELS: Record<Format, string> = {
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
fourball: "Fourball",
|
fourball: "Fourball",
|
||||||
singles: "Singel",
|
singles: "Singel",
|
||||||
|
chapman: "Chapman",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
const HOLE_LABELS: Record<HoleConfig, string> = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" }
|
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" }
|
const SCORING_LABELS: Record<ScoringMode, string> = { stroke: "Slag for slag", hole_result: "Kun hullresultat" }
|
||||||
|
|
||||||
|
// Minimum påkrevd per side FØR laget kan låses (brukt av teamIsComplete OG
|
||||||
|
// av "N spillere per side"-teksten). Shamble er BEVISST forskjellig fra sitt
|
||||||
|
// eget tak (se maxSlotsPerSide) -- fleksibel 2-4, ingen fast antall.
|
||||||
function slotsPerSide(format: Format): number {
|
function slotsPerSide(format: Format): number {
|
||||||
if (format === "singles") return 1
|
if (format === "singles") return 1
|
||||||
if (format === "scramble_4") return 4
|
if (format === "scramble_4" || format === "money_ball") return 4
|
||||||
|
if (format === "shamble") return 2
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maks antall AddSlotForm skal tillate å legge til (backend sin egen
|
||||||
|
// 409 LIMIT_REACHED-grense) -- kun Shamble skiller seg fra slotsPerSide.
|
||||||
|
function maxSlotsPerSide(format: Format): number {
|
||||||
|
return format === "shamble" ? 4 : slotsPerSide(format)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Component ---------------------------------------------------------------
|
// --- Component ---------------------------------------------------------------
|
||||||
|
|
||||||
export function SessionBlindDraw({
|
export function SessionBlindDraw({
|
||||||
|
|
@ -180,14 +207,25 @@ export function SessionBlindDraw({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addParticipant(matchId: string, teamSide: "a" | "b", teamRosterId: string, teeId: string) {
|
async function addParticipant(
|
||||||
|
matchId: string,
|
||||||
|
teamSide: "a" | "b",
|
||||||
|
teamRosterId: string,
|
||||||
|
teeId: string,
|
||||||
|
lineupOrder: number | null = null,
|
||||||
|
) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, {
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ team_side: teamSide, team_roster_id: teamRosterId, tee_id: teeId }),
|
body: JSON.stringify({
|
||||||
|
team_side: teamSide,
|
||||||
|
team_roster_id: teamRosterId,
|
||||||
|
tee_id: teeId,
|
||||||
|
lineup_order: lineupOrder,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`add participant: ${res.status}`)
|
if (!res.ok) throw new Error(`add participant: ${res.status}`)
|
||||||
const created: ApiParticipant = await res.json()
|
const created: ApiParticipant = await res.json()
|
||||||
|
|
@ -375,7 +413,8 @@ export function SessionBlindDraw({
|
||||||
roster={rosters[team.id] ?? []}
|
roster={rosters[team.id] ?? []}
|
||||||
tees={tees}
|
tees={tees}
|
||||||
matches={sortedMatches}
|
matches={sortedMatches}
|
||||||
perSide={perSide}
|
perSide={maxSlotsPerSide(session.format)}
|
||||||
|
format={session.format}
|
||||||
locked={session.locked_team_ids.includes(team.id)}
|
locked={session.locked_team_ids.includes(team.id)}
|
||||||
usedPlayerIds={usedPlayerIds}
|
usedPlayerIds={usedPlayerIds}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
|
|
@ -467,6 +506,7 @@ function TeamColumn({
|
||||||
tees,
|
tees,
|
||||||
matches,
|
matches,
|
||||||
perSide,
|
perSide,
|
||||||
|
format,
|
||||||
locked,
|
locked,
|
||||||
usedPlayerIds,
|
usedPlayerIds,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
|
|
@ -484,9 +524,16 @@ function TeamColumn({
|
||||||
tees: ApiTee[]
|
tees: ApiTee[]
|
||||||
matches: ApiMatch[]
|
matches: ApiMatch[]
|
||||||
perSide: number
|
perSide: number
|
||||||
|
format: Format
|
||||||
locked: boolean
|
locked: boolean
|
||||||
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
||||||
onAddParticipant: (matchId: string, side: "a" | "b", teamRosterId: string, teeId: string) => void
|
onAddParticipant: (
|
||||||
|
matchId: string,
|
||||||
|
side: "a" | "b",
|
||||||
|
teamRosterId: string,
|
||||||
|
teeId: string,
|
||||||
|
lineupOrder?: number | null,
|
||||||
|
) => void
|
||||||
onRemoveParticipant: (matchId: string, participantId: string) => void
|
onRemoveParticipant: (matchId: string, participantId: string) => void
|
||||||
onAddMatch: () => void
|
onAddMatch: () => void
|
||||||
complete: boolean
|
complete: boolean
|
||||||
|
|
@ -545,6 +592,11 @@ function TeamColumn({
|
||||||
>
|
>
|
||||||
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{format === "money_ball" && p.lineup_order !== null && (
|
||||||
|
<span className="inline-flex size-6 items-center justify-center rounded-full bg-primary/15 text-xs font-extrabold text-primary">
|
||||||
|
{p.lineup_order}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-card px-2.5 py-1 text-xs font-semibold text-foreground">
|
<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}
|
{p.tee_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -567,7 +619,15 @@ function TeamColumn({
|
||||||
roster={roster}
|
roster={roster}
|
||||||
tees={tees}
|
tees={tees}
|
||||||
disabledPlayerIds={disabled}
|
disabledPlayerIds={disabled}
|
||||||
onAdd={(teamRosterId, teeId) => onAddParticipant(match.id, side, teamRosterId, teeId)}
|
// Money Ball -- 0-3 ledig, avhengig av hva laget allerede har brukt.
|
||||||
|
availableLineupOrders={
|
||||||
|
format === "money_ball"
|
||||||
|
? [0, 1, 2, 3].filter((n) => !sideParticipants.some((p) => p.lineup_order === n))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onAdd={(teamRosterId, teeId, lineupOrder) =>
|
||||||
|
onAddParticipant(match.id, side, teamRosterId, teeId, lineupOrder)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -636,25 +696,34 @@ function AddSlotForm({
|
||||||
roster,
|
roster,
|
||||||
tees,
|
tees,
|
||||||
disabledPlayerIds,
|
disabledPlayerIds,
|
||||||
|
availableLineupOrders,
|
||||||
onAdd,
|
onAdd,
|
||||||
}: {
|
}: {
|
||||||
roster: ApiRosterEntry[]
|
roster: ApiRosterEntry[]
|
||||||
tees: ApiTee[]
|
tees: ApiTee[]
|
||||||
disabledPlayerIds: Set<string>
|
disabledPlayerIds: Set<string>
|
||||||
onAdd: (teamRosterId: string, teeId: string) => void
|
// Money Ball (2026-07-30) -- ledige 0-3-plasser, null for alle andre formater.
|
||||||
|
availableLineupOrders: number[] | null
|
||||||
|
onAdd: (teamRosterId: string, teeId: string, lineupOrder: number | null) => void
|
||||||
}) {
|
}) {
|
||||||
const [rosterId, setRosterId] = useState("")
|
const [rosterId, setRosterId] = useState("")
|
||||||
const [teeId, setTeeId] = useState("")
|
const [teeId, setTeeId] = useState("")
|
||||||
|
const [lineupOrder, setLineupOrder] = useState("")
|
||||||
|
|
||||||
|
const needsLineupOrder = availableLineupOrders !== null
|
||||||
|
const canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "")
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
if (!rosterId || !teeId) return
|
if (!canAdd) return
|
||||||
onAdd(rosterId, teeId)
|
onAdd(rosterId, teeId, needsLineupOrder ? Number(lineupOrder) : null)
|
||||||
setRosterId("")
|
setRosterId("")
|
||||||
setTeeId("")
|
setTeeId("")
|
||||||
|
setLineupOrder("")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 rounded-xl border border-dashed border-border bg-background/20 p-2">
|
<div className="flex flex-col gap-2 rounded-xl border border-dashed border-border bg-background/20 p-2">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="sr-only">Velg spiller</span>
|
<span className="sr-only">Velg spiller</span>
|
||||||
<select
|
<select
|
||||||
|
|
@ -691,7 +760,7 @@ function AddSlotForm({
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={!rosterId || !teeId}
|
disabled={!canAdd}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="h-11 w-11 shrink-0 rounded-lg"
|
className="h-11 w-11 shrink-0 rounded-lg"
|
||||||
aria-label="Legg til spiller"
|
aria-label="Legg til spiller"
|
||||||
|
|
@ -699,6 +768,24 @@ function AddSlotForm({
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{needsLineupOrder && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">Plass i rekkefølgen (Money Ball)</span>
|
||||||
|
<select
|
||||||
|
value={lineupOrder}
|
||||||
|
onChange={(e) => setLineupOrder(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 plass</option>
|
||||||
|
{availableLineupOrders!.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,25 @@ import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py)
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py)
|
||||||
|
|
||||||
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
// Fire nye org-lagturnering-formater (2026-07-30) -- se tournament-program.tsx.
|
||||||
|
type Format =
|
||||||
|
| "foursome"
|
||||||
|
| "greensome"
|
||||||
|
| "scramble_2"
|
||||||
|
| "scramble_4"
|
||||||
|
| "fourball"
|
||||||
|
| "singles"
|
||||||
|
| "chapman"
|
||||||
|
| "shamble"
|
||||||
|
| "money_ball"
|
||||||
|
| "high_low_high"
|
||||||
type ScoringMode = "stroke" | "hole_result"
|
type ScoringMode = "stroke" | "hole_result"
|
||||||
type HoleConfig = "full_18" | "front_9" | "back_9"
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||||
|
|
||||||
const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"]
|
// Individuell-ball -- hver spiller sin egen hole_score-rad (ikke shamble/
|
||||||
|
// money_ball/high_low_high sin egen aggregering, se app/routers/scoring.py
|
||||||
|
// sin _INDIVIDUAL_FORMATS). Chapman er delt-ball (som foursome), IKKE med her.
|
||||||
|
const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball", "shamble", "money_ball", "high_low_high"]
|
||||||
|
|
||||||
const FORMAT_LABELS: Record<Format, string> = {
|
const FORMAT_LABELS: Record<Format, string> = {
|
||||||
foursome: "Foursome",
|
foursome: "Foursome",
|
||||||
|
|
@ -32,7 +46,16 @@ const FORMAT_LABELS: Record<Format, string> = {
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
fourball: "Fourball",
|
fourball: "Fourball",
|
||||||
singles: "Singel",
|
singles: "Singel",
|
||||||
|
chapman: "Chapman",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
|
// To-sidede formater (ADR-039-lignende, org-lag) -- gater Nassau-panelet
|
||||||
|
// (ren tilleggsvisning av 18-hulls to-sidet data, ingen egen play_format).
|
||||||
|
const TWO_SIDED_FORMATS: Format[] = [
|
||||||
|
"singles", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
]
|
||||||
|
|
||||||
type ApiTeam = { id: string; name: string; color: string | null }
|
type ApiTeam = { id: string; name: string; color: string | null }
|
||||||
|
|
||||||
|
|
@ -618,6 +641,9 @@ export function SessionScorecard({
|
||||||
units={units}
|
units={units}
|
||||||
scoringMode={session.scoring_mode}
|
scoringMode={session.scoring_mode}
|
||||||
holeConfig={session.hole_config}
|
holeConfig={session.hole_config}
|
||||||
|
format={session.format}
|
||||||
|
organizationId={organizationId}
|
||||||
|
matchId={matchId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -657,6 +683,13 @@ export function SessionScorecard({
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer av et
|
||||||
|
EKSISTERENDE to-sidet format, kun for 18-hulls økter. Ren
|
||||||
|
tilleggsvisning, ikke et eget format. */}
|
||||||
|
{TWO_SIDED_FORMATS.includes(session.format) && session.hole_config === "full_18" && (
|
||||||
|
<NassauPanel organizationId={organizationId} matchId={matchId} refreshKey={scorecard.stroke_entries?.length ?? 0} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -669,6 +702,14 @@ export function SessionScorecard({
|
||||||
</button>
|
</button>
|
||||||
{showSummary && (
|
{showSummary && (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
|
{session.format === "high_low_high" ? (
|
||||||
|
<HighLowHighPanel
|
||||||
|
organizationId={organizationId}
|
||||||
|
matchId={matchId}
|
||||||
|
teams={teams}
|
||||||
|
refreshKey={scorecard.stroke_entries?.length ?? 0}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TournamentMatchGrid
|
<TournamentMatchGrid
|
||||||
teams={teams}
|
teams={teams}
|
||||||
units={units}
|
units={units}
|
||||||
|
|
@ -677,6 +718,7 @@ export function SessionScorecard({
|
||||||
scoringMode={session.scoring_mode}
|
scoringMode={session.scoring_mode}
|
||||||
holeConfig={session.hole_config}
|
holeConfig={session.hole_config}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{!isIndividual && session.scoring_mode === "stroke" && match && (
|
{!isIndividual && session.scoring_mode === "stroke" && match && (
|
||||||
<SelectedDriverSummary
|
<SelectedDriverSummary
|
||||||
teams={teams}
|
teams={teams}
|
||||||
|
|
@ -1382,6 +1424,184 @@ function TournamentMatchGrid({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data ---
|
||||||
|
// Tre parallelle vinduer (front9/back9/overall) av et eksisterende to-sidet
|
||||||
|
// format, ingen egen lagring (se app/routers/scoring.py sin get_nassau_result).
|
||||||
|
|
||||||
|
type ApiNassauWindow = {
|
||||||
|
lead: number
|
||||||
|
holes_played: number
|
||||||
|
holes_remaining: number
|
||||||
|
is_closed: boolean
|
||||||
|
is_dormie: boolean
|
||||||
|
status_text: string
|
||||||
|
}
|
||||||
|
type ApiNassauResult = {
|
||||||
|
ready: boolean
|
||||||
|
front9: ApiNassauWindow | null
|
||||||
|
back9: ApiNassauWindow | null
|
||||||
|
overall: ApiNassauWindow | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function NassauWindowCard({ label, window }: { label: string; window: ApiNassauWindow | null }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-background px-3 py-3">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
|
{window && window.holes_played > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-extrabold tabular-nums text-foreground">{window.status_text}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{window.holes_played} hull spilt</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">–</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NassauPanel({
|
||||||
|
organizationId,
|
||||||
|
matchId,
|
||||||
|
refreshKey,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
matchId: string
|
||||||
|
refreshKey: number
|
||||||
|
}) {
|
||||||
|
const [result, setResult] = useState<ApiNassauResult | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/orgs/${organizationId}/matches/${matchId}/nassau`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiNassauResult | null) => {
|
||||||
|
if (!cancelled) setResult(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, matchId, refreshKey])
|
||||||
|
|
||||||
|
if (!result?.ready) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<span className="text-sm font-bold text-foreground">Nassau</span>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<NassauWindowCard label="Ut (1-9)" window={result.front9} />
|
||||||
|
<NassauWindowCard label="Inn (10-18)" window={result.back9} />
|
||||||
|
<NassauWindowCard label="Totalt" window={result.overall} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- High-low-high (2026-07-30) -- egen, dedikert visning ------------------
|
||||||
|
// Passer IKKE inn i TournamentMatchGrid over: _compute_hole_results returnerer
|
||||||
|
// bevisst [] for dette formatet (litterale 0-2-poeng per hull, ikke en
|
||||||
|
// ternær vinn/tap/delt-modell), så scorecard.holes ville alltid vært tom her
|
||||||
|
// selv om ekte slag er registrert. Egen henting mot det dedikerte
|
||||||
|
// /high-low-high-endepunktet, samme mønster som round-detail.tsx sin
|
||||||
|
// FormatResultPanel-gren for dette formatet.
|
||||||
|
|
||||||
|
type ApiHighLowHighHole = { hole_number: number; points_a: number; points_b: number }
|
||||||
|
type ApiHighLowHighResult = {
|
||||||
|
ready: boolean
|
||||||
|
points_a: number | null
|
||||||
|
points_b: number | null
|
||||||
|
holes: ApiHighLowHighHole[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighLowHighPanel({
|
||||||
|
organizationId,
|
||||||
|
matchId,
|
||||||
|
teams,
|
||||||
|
refreshKey,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
matchId: string
|
||||||
|
teams: [ApiTeam, ApiTeam]
|
||||||
|
refreshKey: number
|
||||||
|
}) {
|
||||||
|
const [result, setResult] = useState<ApiHighLowHighResult | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/orgs/${organizationId}/matches/${matchId}/high-low-high`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiHighLowHighResult | null) => {
|
||||||
|
if (!cancelled) setResult(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, matchId, refreshKey])
|
||||||
|
|
||||||
|
if (!result?.ready) {
|
||||||
|
return <p className="px-4 py-4 text-sm text-muted-foreground">Venter på at begge sider er komplette.</p>
|
||||||
|
}
|
||||||
|
const holes = result.holes ?? []
|
||||||
|
if (holes.length === 0) {
|
||||||
|
return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3 sm:p-4">
|
||||||
|
<div className="grid grid-cols-3 items-center gap-2 rounded-2xl border border-border bg-background px-4 py-3">
|
||||||
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
|
<span className="text-xs font-bold text-foreground">{teams[0].name}</span>
|
||||||
|
<span className="text-2xl font-extrabold tabular-nums text-foreground">{result.points_a ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Poeng
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
|
<span className="text-xs font-bold text-foreground">{teams[1].name}</span>
|
||||||
|
<span className="text-2xl font-extrabold tabular-nums text-foreground">{result.points_b ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground">
|
||||||
|
Hull
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<th key={h.hole_number} scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold tabular-nums text-muted-foreground">
|
||||||
|
{h.hole_number}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className="border border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||||||
|
{teams[0].name}
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className="border border-border py-1.5 text-center text-xs font-bold tabular-nums text-foreground">
|
||||||
|
{h.points_a}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" className="border border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||||||
|
{teams[1].name}
|
||||||
|
</th>
|
||||||
|
{holes.map((h) => (
|
||||||
|
<td key={h.hole_number} className="border border-border py-1.5 text-center text-xs font-bold tabular-nums text-foreground">
|
||||||
|
{h.points_b}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function scoreToParLabel(value: number, par: number): string {
|
function scoreToParLabel(value: number, par: number): string {
|
||||||
const diff = value - par
|
const diff = value - par
|
||||||
if (diff === 0) return "Par"
|
if (diff === 0) return "Par"
|
||||||
|
|
@ -1400,6 +1620,9 @@ function DecidedView({
|
||||||
units,
|
units,
|
||||||
scoringMode,
|
scoringMode,
|
||||||
holeConfig,
|
holeConfig,
|
||||||
|
format,
|
||||||
|
organizationId,
|
||||||
|
matchId,
|
||||||
}: {
|
}: {
|
||||||
teams: [ApiTeam, ApiTeam]
|
teams: [ApiTeam, ApiTeam]
|
||||||
scorecard: ApiScorecard
|
scorecard: ApiScorecard
|
||||||
|
|
@ -1407,6 +1630,9 @@ function DecidedView({
|
||||||
units: Unit[]
|
units: Unit[]
|
||||||
scoringMode: ScoringMode
|
scoringMode: ScoringMode
|
||||||
holeConfig: HoleConfig
|
holeConfig: HoleConfig
|
||||||
|
format: Format
|
||||||
|
organizationId: string
|
||||||
|
matchId: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
@ -1445,6 +1671,14 @@ function DecidedView({
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
|
||||||
<div className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
|
<div className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
|
||||||
|
{format === "high_low_high" ? (
|
||||||
|
<HighLowHighPanel
|
||||||
|
organizationId={organizationId}
|
||||||
|
matchId={matchId}
|
||||||
|
teams={teams}
|
||||||
|
refreshKey={scorecard.stroke_entries?.length ?? 0}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TournamentMatchGrid
|
<TournamentMatchGrid
|
||||||
teams={teams}
|
teams={teams}
|
||||||
units={units}
|
units={units}
|
||||||
|
|
@ -1453,6 +1687,7 @@ function DecidedView({
|
||||||
scoringMode={scoringMode}
|
scoringMode={scoringMode}
|
||||||
holeConfig={holeConfig}
|
holeConfig={holeConfig}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
|
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
|
||||||
Resultatet kan ikke endres etter at matchen er avgjort.
|
Resultatet kan ikke endres etter at matchen er avgjort.
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,29 @@ import { cn } from "@/lib/utils"
|
||||||
// (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever
|
// (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever
|
||||||
// scramble_2/scramble_4 (antall spillere per side er del av selve formatet,
|
// scramble_2/scramble_4 (antall spillere per side er del av selve formatet,
|
||||||
// ikke en egen bryter).
|
// ikke en egen bryter).
|
||||||
type SessionFormat = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
// Fire nye org-lagturnering-formater (2026-07-30, se ADR/plan) -- flatt-
|
||||||
|
// felt-formatene (Københavner/BBB/Flag) hører ALDRI hjemme her (ADR-011s
|
||||||
|
// to-lags-modell passer strukturelt ikke), kun individuelle org-turneringer.
|
||||||
|
type SessionFormat =
|
||||||
|
| "foursome"
|
||||||
|
| "greensome"
|
||||||
|
| "scramble_2"
|
||||||
|
| "scramble_4"
|
||||||
|
| "fourball"
|
||||||
|
| "singles"
|
||||||
|
| "chapman"
|
||||||
|
| "shamble"
|
||||||
|
| "money_ball"
|
||||||
|
| "high_low_high"
|
||||||
type HoleConfig = "full_18" | "front_9" | "back_9"
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||||
type ScoringMode = "stroke" | "hole_result"
|
type ScoringMode = "stroke" | "hole_result"
|
||||||
|
|
||||||
// Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren
|
// Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren
|
||||||
// -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined"
|
// -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined"
|
||||||
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT).
|
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT). Chapman er
|
||||||
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4"])
|
// delt-ball (som foursome); shamble/money_ball/high_low_high er
|
||||||
|
// individuell-ball (som fourball) selv om alle fire er to-sidede formater.
|
||||||
|
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
|
||||||
|
|
||||||
type ApiCourse = {
|
type ApiCourse = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -79,6 +94,10 @@ const FORMAT_LABELS: Record<SessionFormat, string> = {
|
||||||
scramble_4: "Scramble (4 spillere)",
|
scramble_4: "Scramble (4 spillere)",
|
||||||
fourball: "Fourball",
|
fourball: "Fourball",
|
||||||
singles: "Singel",
|
singles: "Singel",
|
||||||
|
chapman: "Chapman",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOLE_LABELS: Record<HoleConfig, string> = {
|
const HOLE_LABELS: Record<HoleConfig, string> = {
|
||||||
|
|
@ -179,6 +198,7 @@ export function TournamentProgram({
|
||||||
courseId: string | null
|
courseId: string | null
|
||||||
newCourseName: string
|
newCourseName: string
|
||||||
allowanceOverride: Record<string, unknown> | null
|
allowanceOverride: Record<string, unknown> | null
|
||||||
|
shambleBestN: number | null
|
||||||
}) {
|
}) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -199,6 +219,7 @@ export function TournamentProgram({
|
||||||
scheduled_at: input.scheduledAt ?? null,
|
scheduled_at: input.scheduledAt ?? null,
|
||||||
tee_interval_minutes: input.teeIntervalMinutes ?? null,
|
tee_interval_minutes: input.teeIntervalMinutes ?? null,
|
||||||
start_hole: input.startHole,
|
start_hole: input.startHole,
|
||||||
|
shamble_best_n: input.format === "shamble" ? input.shambleBestN : null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`create session: ${res.status}`)
|
if (!res.ok) throw new Error(`create session: ${res.status}`)
|
||||||
|
|
@ -923,10 +944,12 @@ function CreateSessionCard({
|
||||||
courseId: string | null
|
courseId: string | null
|
||||||
newCourseName: string
|
newCourseName: string
|
||||||
allowanceOverride: Record<string, unknown> | null
|
allowanceOverride: Record<string, unknown> | null
|
||||||
|
shambleBestN: number | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [format, setFormat] = useState<SessionFormat>("foursome")
|
const [format, setFormat] = useState<SessionFormat>("foursome")
|
||||||
|
const [shambleBestN, setShambleBestN] = useState(1)
|
||||||
const [holeConfig, setHoleConfig] = useState<HoleConfig>("full_18")
|
const [holeConfig, setHoleConfig] = useState<HoleConfig>("full_18")
|
||||||
const [scoringMode, setScoringMode] = useState<ScoringMode>("stroke")
|
const [scoringMode, setScoringMode] = useState<ScoringMode>("stroke")
|
||||||
const [points, setPoints] = useState("1")
|
const [points, setPoints] = useState("1")
|
||||||
|
|
@ -1005,11 +1028,13 @@ function CreateSessionCard({
|
||||||
courseId: selectedCourseId,
|
courseId: selectedCourseId,
|
||||||
newCourseName: trimmedCourseQuery,
|
newCourseName: trimmedCourseQuery,
|
||||||
allowanceOverride: buildAllowanceOverride(),
|
allowanceOverride: buildAllowanceOverride(),
|
||||||
|
shambleBestN: format === "shamble" ? shambleBestN : null,
|
||||||
})
|
})
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
|
|
||||||
setName("")
|
setName("")
|
||||||
setFormat("foursome")
|
setFormat("foursome")
|
||||||
|
setShambleBestN(1)
|
||||||
setHoleConfig("full_18")
|
setHoleConfig("full_18")
|
||||||
setScoringMode("stroke")
|
setScoringMode("stroke")
|
||||||
setPoints("1")
|
setPoints("1")
|
||||||
|
|
@ -1065,6 +1090,27 @@ function CreateSessionCard({
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{format === "shamble" && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Beste N av laget teller per hull</span>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<SegmentButton key={n} selected={shambleBestN === n} onClick={() => setShambleBestN(n)}>
|
||||||
|
{n}
|
||||||
|
</SegmentButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Kan ikke endres etter at økten er opprettet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{format === "money_ball" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Nøyaktig 4 spillere per side -- rekkefølgen (hvem som er "money ball" på hvilket hull)
|
||||||
|
tildeles når spillerne legges til matchen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ type ApiFormatResult = {
|
||||||
match_status_text: string | null
|
match_status_text: string | null
|
||||||
hole_results: string[] | null
|
hole_results: string[] | null
|
||||||
skins_won: Record<string, number> | null
|
skins_won: Record<string, number> | null
|
||||||
|
// High-low-high (2026-07-30) -- literal poengsum, IKKE match-play
|
||||||
|
// "X UP" (match_status_text settes aldri for dette formatet).
|
||||||
|
hlh_points_a: number | null
|
||||||
|
hlh_points_b: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
|
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
|
||||||
|
|
@ -84,8 +88,17 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
greensome: "Greensome",
|
greensome: "Greensome",
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
|
chapman: "Chapman",
|
||||||
|
copenhagen: "Københavner",
|
||||||
|
bbb: "Bingo Bango Bongo",
|
||||||
|
flag: "Flaggturnering",
|
||||||
|
shamble: "Shamble",
|
||||||
|
money_ball: "Money Ball",
|
||||||
|
high_low_high: "High-low-high",
|
||||||
}
|
}
|
||||||
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
|
const TWO_SIDED_FORMATS = new Set([
|
||||||
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
])
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
|
|
@ -171,7 +184,11 @@ function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSid
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
|
const statusText = result.match_status_text
|
||||||
|
? substituteSideLabels(result.match_status_text, sides)
|
||||||
|
: result.hlh_points_a !== null && result.hlh_points_b !== null
|
||||||
|
? `${result.hlh_points_a}-${result.hlh_points_b}`
|
||||||
|
: null
|
||||||
const lead = result.match_lead ?? 0
|
const lead = result.match_lead ?? 0
|
||||||
const holesPlayed = result.match_holes_played ?? 0
|
const holesPlayed = result.match_holes_played ?? 0
|
||||||
const holesRemaining = result.match_holes_remaining ?? 0
|
const holesRemaining = result.match_holes_remaining ?? 0
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue