From 968ea79184e0eda577d038640a105fd5ed69cab2 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 1 Aug 2026 06:51:53 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/settings.local.json | 3 +- CLAUDE.md | 68 ++- app/config.py | 6 + app/email.py | 16 + app/routers/auth.py | 32 +- app/routers/individual_tournaments.py | 29 + app/routers/rounds.py | 41 +- .../individual-tournament-detail.tsx | 262 ++++++++- frontend/components/new-round.tsx | 109 +++- frontend/components/round-detail.tsx | 506 +++++++++++++++++- frontend/components/round-leaderboard.tsx | 4 +- frontend/components/round-scorecard.tsx | 11 +- frontend/components/round-stats.tsx | 3 +- frontend/components/session-blind-draw.tsx | 189 +++++-- frontend/components/session-scorecard.tsx | 271 +++++++++- frontend/components/tournament-program.tsx | 52 +- frontend/components/watch-round.tsx | 21 +- frontend/tsconfig.tsbuildinfo | 2 +- 18 files changed, 1523 insertions(+), 102 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 70d29e5..bc16562 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -381,7 +381,8 @@ "Bash(python3 test_round_edit_delete.py)", "Bash(python3 test_round_starthole_time_nearby.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": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 69ec615..e27990f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5855,8 +5855,8 @@ Ferdig og verifisert: rent, `/health`/`/dashboard`/scorekort-siden → 200, `teeoff.no` upåvirket. -- **Åtte nye turneringsformater: BACKEND FERDIG OG SCRATCH-VERIFISERT, - IKKE ENNÅ RULLET UT (2026-07-30):** brukeren ba om å ta fatt på det +- **Åtte nye turneringsformater: BACKEND FERDIG, SCRATCH-VERIFISERT OG + RULLET UT LIVE (2026-07-30):** brukeren ba om å ta fatt på det lenge noterte "flere turneringsformater"-punktet fra 2026-07-19. Listen ble utvidet fra fire til åtte (Shamble, Chapman/Pinehurst, Bingo Bango 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). **IKKE bygget ennå, bevisst neste steg:** frontend for samtlige åtte formater (nye format-valg i opprett-runde/opprett-økt, nye - resultatvisninger). **Migrasjoner 041-050 BYGGET OG SCRATCH-VERIFISERT, - IKKE ENNÅ rullet ut mot ekte `teecup_db`** -- venter på eksplisitt - bekreftelse fra bruker. Se FEATURE_BACKLOG.md for full detalj per - format. + resultatvisninger) — backend er fullt funksjonelt og testet, men + ubrukelig fra selve appen inntil frontend bygges, samme lagdelings- + mønster som ADR-037/038/039. + **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: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet @@ -6158,3 +6203,14 @@ Neste steg: reell innvilget tillatelse (automatisert testmiljø hadde `Notification.permission` forhåndssatt til "denied") — bruker bør 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. diff --git a/app/config.py b/app/config.py index 52d64e1..bd079af 100644 --- a/app/config.py +++ b/app/config.py @@ -72,6 +72,12 @@ class Settings: # scratch-/dev-testing kan overstyre uten kodeendring. 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, # _required. INTERNAL_ENDPOINT (container-til-container, ingen Caddy) # brukes KUN til engangs bucket-/policy-oppsett ved oppstart. diff --git a/app/email.py b/app/email.py index abca99b..35e0f9c 100644 --- a/app/email.py +++ b/app/email.py @@ -320,3 +320,19 @@ async def send_session_result_email( token=raw_token, ) 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) diff --git a/app/routers/auth.py b/app/routers/auth.py index 03b00d2..51a55be 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -68,6 +68,7 @@ from ..db import org_connection, plain_connection from ..email import ( send_email_change_confirmation, send_magic_link_email, + send_new_account_alert_email, send_secondary_email_verification, 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( "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: user_id = secondary_owner_id else: placeholder_name = email.split("@")[0] + new_account_display_name = body.display_name or placeholder_name user_id = await conn.fetchval( """ 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 """, email, - body.display_name or placeholder_name, + new_account_display_name, token_row["locale"], ) if user_id is None: # En annen samtidig verifisering for samme e-post vant # innsettingen (eller brukeren fantes allerede) -- # 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) + else: + is_new_account = True # Koble enhver player-rad (ADR-017) OG enhver ventende # 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 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) -- # create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på # en rå UUID. Fant under scratch-testing: str() FØR den sendes videre. diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 88742a8..eb91bb8 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -768,6 +768,35 @@ async def update_bbb_hole( 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( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes/{hole_number}", diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 06743a7..ad55387 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -2343,10 +2343,20 @@ async def update_participant( *values, ) - # Side-tildeling endret (ADR-039) -- regn playing_handicap for begge - # sider på nytt FØR raden under leses, slik at responsen reflekterer - # den ferske verdien (billig no-op hvis fortsatt ikke komplett). - if "round_side_id" in updates and current["play_format"] in _TWO_SIDED_FORMATS: + # Side-tildeling ELLER rating (HCP/utslag/kjønn) endret (ADR-039) -- + # regn playing_handicap for begge sider på nytt FØR raden under + # leses, slik at responsen reflekterer den ferske verdien (billig + # 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"]) row = await conn.fetchrow( @@ -2672,6 +2682,29 @@ async def update_bbb_hole( 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/ # greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret), diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 8e06b02..14fd8a6 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -56,6 +56,8 @@ type ApiTournamentInfo = { join_code: string format_type: string scoring_method: string | null + // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). + bbb_sweep_bonus_enabled: boolean } type ApiParticipant = { @@ -103,6 +105,23 @@ type ApiLeaderboardEntry = { gross_total: number | null net_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 } @@ -119,6 +138,9 @@ const SCORING_METHOD_LABELS: Record = { stroke_gross: "Bruttoslagspill", stroke_net: "Nettoslagspill", stableford: "Stableford", + copenhagen: "Københavner", + bingo_bango_bongo: "Bingo Bango Bongo", + flag: "Flaggturnering", } 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) { setError(null) const res = await fetch(`${base}/participants`, { @@ -418,6 +458,7 @@ export function IndividualTournamentDetail({ rounds={rounds} courses={courses} onUpdateScoringMethod={updateScoringMethod} + onUpdateBbbSweepBonus={updateBbbSweepBonus} onAddParticipant={addParticipant} onAddNewPlayer={addNewPlayerAndParticipant} onRemoveParticipant={removeParticipant} @@ -428,7 +469,7 @@ export function IndividualTournamentDetail({ /> )} {tab === "score" && ( - + )} {tab === "leaderboard" && ( @@ -479,6 +520,7 @@ function SetupTab({ rounds, courses, onUpdateScoringMethod, + onUpdateBbbSweepBonus, onAddParticipant, onAddNewPlayer, onRemoveParticipant, @@ -494,6 +536,7 @@ function SetupTab({ rounds: ApiRound[] courses: ApiCourse[] onUpdateScoringMethod: (method: string) => void + onUpdateBbbSweepBonus: (enabled: boolean) => void onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise onRemoveParticipant: (id: string) => Promise @@ -539,6 +582,22 @@ function SetupTab({ ))} + {tournament.scoring_method === "bingo_bango_bongo" && ( + + )} void }) { const [roundId, setRoundId] = useState(rounds[0]?.id ?? "") @@ -1292,6 +1353,9 @@ function ScoreTab({ const [participantId, setParticipantId] = useState("") const [holes, setHoles] = useState(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(() => { if (!roundId) return @@ -1334,6 +1398,7 @@ function ScoreTab({ } const updated: ApiHole = await res.json() setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev)) + setRefreshTick((n) => n + 1) } if (rounds.length === 0) { @@ -1396,12 +1461,194 @@ function ScoreTab({ {round && holes && ( )} + + {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon, + uavhengig av gross_strokes-registreringen over. */} + {round && scoringMethod === "bingo_bango_bongo" && ( + + )} + + {/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke + akkumulerbart på tvers av runder, se LeaderboardTab). */} + {round && scoringMethod === "flag" && ( + + )} )} ) } +// --- 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 ( +
+ {label} +
+ {participants.map((p) => { + const active = selectedId === p.tournament_participant_id + return ( + + ) + })} +
+
+ ) +} + +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>({}) + + useEffect(() => { + let cancelled = false + getJson(`${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 ( +
+
+ Bingo Bango Bongo + +
+ void set("bingo_participant_id", id)} + /> + void set("bango_participant_id", id)} + /> + void set("bongo_participant_id", id)} + /> +
+ ) +} + +// --- Flaggturnering -- per-runde-resultat (2026-07-30) ---------------------- + +function FlagRoundPanel({ + base, + round, + refreshTick, +}: { + base: string + round: ApiRound + refreshTick: number +}) { + const [results, setResults] = useState(null) + + useEffect(() => { + let cancelled = false + getJson(`${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 ( +

+ Venter på at handicap beregnes for minst én spiller. +

+ ) + } + + return ( +
+ Flaggturnering +
    + {results.map((r) => ( +
  • + {r.player_name} + + {r.holes_completed} hull + + {r.ran_out ? "gikk tom for slag" : `${r.strokes_remaining} slag igjen`} + + +
  • + ))} +
+
+ ) +} + type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" 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 } { if (method === "stableford") return { label: "poeng", value: entry.stableford_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 } } @@ -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 ( +

+ Flaggturnering vises per runde -- se resultatet under «Scorekort» for hver runde. +

+ ) + } + if (entries.length === 0) { return (

Ingen deltakere ennå.

diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx index c10bc34..98a9343 100644 --- a/frontend/components/new-round.tsx +++ b/frontend/components/new-round.tsx @@ -85,6 +85,18 @@ type PlayFormat = | "greensome" | "scramble_2" | "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 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: "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: "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 --------------------------------------------------------------- @@ -262,6 +281,8 @@ export function NewRound() { excludeFromHandicap: boolean skinsScoring: SkinsScoring | null skinsTieHandling: SkinsTieHandling | null + bbbSweepBonusEnabled: boolean + shambleBestN: number | null visibilityMode: VisibilityMode visibleCategories: string[] }): Promise<{ id: string }> { @@ -278,6 +299,8 @@ export function NewRound() { exclude_owner_from_handicap: payload.excludeFromHandicap, skins_scoring: payload.skinsScoring, skins_tie_handling: payload.skinsTieHandling, + bbb_sweep_bonus_enabled: payload.bbbSweepBonusEnabled, + shamble_best_n: payload.shambleBestN, visibility_mode: payload.visibilityMode, visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [], flight_group_id: flightGroupId || undefined, @@ -1194,6 +1217,8 @@ function ConfirmStep({ excludeFromHandicap: boolean skinsScoring: SkinsScoring | null skinsTieHandling: SkinsTieHandling | null + bbbSweepBonusEnabled: boolean + shambleBestN: number | null visibilityMode: VisibilityMode visibleCategories: string[] }) => Promise<{ id: string }> @@ -1220,6 +1245,9 @@ function ConfirmStep({ // play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare. const [skinsScoring, setSkinsScoring] = useState("net") const [skinsTieHandling, setSkinsTieHandling] = useState("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". const [visibilityMode, setVisibilityMode] = useState("private") // 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) - // ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble - // krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må - // finnes først). Vises som en forklarende note her, ikke et skjema. - const needsSidesSetup = playFormat !== "stroke" && playFormat !== "stableford" && playFormat !== "skins" + // ADR-039 Beslutning A -- to-sidede formater krever sider, opprettet + // ETTERPÅ inne i selve runden (deltakerne må finnes først). Vises som en + // forklarende note her, ikke et skjema. Shamble/money_ball/copenhagen/ + // 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([ + "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() { if (!selectedTee) return @@ -1265,6 +1300,8 @@ function ConfirmStep({ excludeFromHandicap, skinsScoring: playFormat === "skins" ? skinsScoring : null, skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null, + bbbSweepBonusEnabled: playFormat === "bbb" ? bbbSweepBonusEnabled : false, + shambleBestN: playFormat === "shamble" ? shambleBestN : null, visibilityMode, visibleCategories, }) @@ -1527,6 +1564,70 @@ function ConfirmStep({ )} + {playFormat === "bbb" && ( + + )} + + {playFormat === "shamble" && ( +
+
+ + Beste N av laget teller per hull + +
+ {[1, 2, 3, 4].map((n) => ( + + ))} +
+
+

+ 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. +

+
+ )} + + {needsLineupSetup && ( +

+ Nøyaktig 4 spillere -- du setter opp hvem som er "money ball" på hvilket hull inne i + runden, etter at medspillerne er lagt til. +

+ )} + + {playFormat === "copenhagen" && ( +

+ Nøyaktig 3 spillere -- legg til medspillere etter at runden er opprettet. +

+ )} + {needsSidesSetup && (

Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 6d632b1..58a723b 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -80,6 +80,8 @@ type Player = { // og den format-relative match-play-enheten (ikke samme som courseHandicap). roundSideId: string | null playingHandicap: number | null + // Money Ball (2026-07-30) -- se ApiParticipant.lineup_order. + lineupOrder: number | null } type HoleStat = { @@ -186,6 +188,9 @@ type ApiParticipant = { // ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som // course_handicap_snapshot (den absolutte WHS-verdien). 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 = { @@ -252,8 +257,16 @@ const VISIBILITY_CATEGORY_OPTIONS = [ // (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for // å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid // server-side -- disse styrer bare presentasjon. -const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"]) -const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4"]) +// Åtte nye formater (2026-07-30). Chapman er to-sidet+delt-ball (som +// 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 = { match: 1, fourball: 2, @@ -261,6 +274,8 @@ const SIDE_PLAYER_COUNT: Record = { greensome: 2, scramble_2: 2, scramble_4: 4, + chapman: 2, + high_low_high: 2, } const FORMAT_LABELS: Record = { stroke: "Slagspill", @@ -272,7 +287,17 @@ const FORMAT_LABELS: Record = { greensome: "Greensome", scramble_2: "Scramble (2)", 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 // completed_at - started_at (begge ekte tidsstempler) -- ingen egen @@ -507,6 +532,41 @@ export function RoundDetail({ roundId }: { roundId: string }) { } }, [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>({}) + 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) { + 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(() => { let cancelled = false fetch("/auth/me", { credentials: "include" }) @@ -739,6 +799,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { excludeFromHandicap: p.exclude_from_handicap, roundSideId: p.round_side_id, playingHandicap: p.playing_handicap, + lineupOrder: p.lineup_order, })) ?? [] // 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. */} + {/* 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 && ( + + )} + {/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md. 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. */} + {/* 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 && ( + + )} + + {/* 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 && ( + + )} + {/* 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 fullført (completed flipper til true). */} @@ -2430,6 +2521,8 @@ type ApiFormatHoleEntry = { net: number | null strokes_received: number | null counted: boolean | null + // Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE hullet. + points: number | null } type ApiFormatHoleOut = { @@ -2442,6 +2535,20 @@ type ApiFormatHoleOut = { 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 = { play_format: string ready: boolean @@ -2453,6 +2560,15 @@ type ApiFormatResult = { match_status_text: string | null hole_results: string[] | null skins_won: Record | null + // Åtte nye formater (2026-07-30) -- se app/routers/rounds.py sin + // RoundFormatResultOut for full kontrakt. + copenhagen_points: Record | null + bbb_points: Record | null + flag_results: Record | 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 } @@ -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 ( +

+ Københavner -- poengtavle +
    + {rows.map(({ player, points: pts }) => ( +
  • + {player.name} + {pts} +
  • + ))} +
+
+ ) + } + + // 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 ( +
+ Bingo Bango Bongo -- poengtavle +
    + {rows.map(({ player, points: pts }) => ( +
  • + {player.name} + {pts} +
  • + ))} +
+
+ ) + } + + // 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 ( +
+ Flaggturnering + {rows.length === 0 ? ( +

Venter på at handicap beregnes for minst én spiller.

+ ) : ( +
    + {rows.map((r) => ( +
  • + {r.label} + + + {r.holes_completed} hull + + + {r.ran_out + ? "gikk tom for slag" + : `${r.strokes_remaining} slag igjen`} + + +
  • + ))} +
+ )} +
+ ) + } + + // 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 ( +
+ + {FORMAT_LABELS[round.play_format]} -- lagets samlede score + + {teamScore ?? "–"} + {teamScore === null && ( +

Venter på at alle spillerne registrerer på minst ett hull.

+ )} +
+ ) + } + + // 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 ( +
+ Venter på at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises. +
+ ) + } + 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 ( +
+ High-low-high +
+
+ +
+
+
+ {holesPlayed > 0 ? ( + <> + + {pa}-{pb} + + {holesPlayed} hull spilt + + ) : ( + Ingen hull spilt ennå + )} +
+
+
+ +
+
+
+ ) + } + + // To-sidede formater (match/fourball/foursome/greensome/scramble/chapman). if (!result.ready) { return (
@@ -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 ( +
+ {label} + {window && window.holes_played > 0 ? ( + <> + {window.status_text} + {window.holes_played} hull spilt + + ) : ( + + )} +
+ ) +} + +function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) { + const [result, setResult] = useState(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 ( +
+ Nassau +
+ + + +
+
+ ) +} + +// --- 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 ( +
+ {label} +
+ {players.map((p) => { + const active = selectedId === p.id + return ( + + ) + })} +
+
+ ) +} + +function BBBHolePicker({ + players, + holeNumber, + selection, + onSet, +}: { + players: Player[] + holeNumber: number + selection: ApiBBBHole | null + onSet: (holeNumber: number, body: Record) => Promise +}) { + return ( +
+ Bingo Bango Bongo -- hull {holeNumber} + void onSet(holeNumber, { bingo_participant_id: id })} + /> + void onSet(holeNumber, { bango_participant_id: id })} + /> + void onSet(holeNumber, { bongo_participant_id: id })} + /> +
+ ) +} + +// --- 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) => Promise<{ ok: true } | { ok: false; message: string }> +}) { + const [error, setError] = useState(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 ( +
+ Money Ball -- rekkefølge +

+ Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull. +

+ {error &&

{error}

} +
    + {players.map((p) => ( +
  • + {p.name} +
    + {[0, 1, 2, 3].map((n) => ( + + ))} +
    +
  • + ))} +
+
+ ) +} + // --- Player tabs ----------------------------------------------------------- // --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) --- @@ -4362,7 +4840,16 @@ function ScorecardGrid({ ) })} - {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" && ( Stilling @@ -4756,7 +5243,16 @@ function SideScorecardGrid({ ) })} - {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" && ( Stilling diff --git a/frontend/components/round-leaderboard.tsx b/frontend/components/round-leaderboard.tsx index a57da3f..fd449c1 100644 --- a/frontend/components/round-leaderboard.tsx +++ b/frontend/components/round-leaderboard.tsx @@ -78,7 +78,9 @@ type ApiRoundHeader = { // noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én // score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville // 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" }) diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx index 46e8a78..c6fafaa 100644 --- a/frontend/components/round-scorecard.tsx +++ b/frontend/components/round-scorecard.tsx @@ -73,8 +73,8 @@ type ApiHole = { strokes_received: number | null } -const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"] -const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS] +const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"] +const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS] const FORMAT_LABELS: Record = { stroke: "Slagspill", stableford: "Stableford", @@ -85,6 +85,13 @@ const FORMAT_LABELS: Record = { greensome: "Greensome", scramble_2: "Scramble (2)", 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" }) diff --git a/frontend/components/round-stats.tsx b/frontend/components/round-stats.tsx index b6316dc..09e2241 100644 --- a/frontend/components/round-stats.tsx +++ b/frontend/components/round-stats.tsx @@ -400,12 +400,13 @@ type ApiSideHole = { // slag-for-slag-registrering) er strukturelt umulig å vise for disse // formatene. Vist som en forklarende melding i stedet for en stille tom/ // 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 = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", + chapman: "Chapman", } type ApiHole = { diff --git a/frontend/components/session-blind-draw.tsx b/frontend/components/session-blind-draw.tsx index d88529c..fba2678 100644 --- a/frontend/components/session-blind-draw.tsx +++ b/frontend/components/session-blind-draw.tsx @@ -9,7 +9,18 @@ import { cn } from "@/lib/utils" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py) -type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" +// 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 ScoringMode = "stroke" | "hole_result" @@ -49,6 +60,8 @@ type ApiParticipant = { player_name: string tee_id: string tee_name: string + // Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3). + lineup_order: number | null } type ApiMatch = { @@ -73,16 +86,30 @@ const FORMAT_LABELS: Record = { scramble_4: "Scramble (4)", fourball: "Fourball", singles: "Singel", + chapman: "Chapman", + shamble: "Shamble", + money_ball: "Money Ball", + high_low_high: "High-low-high", } const HOLE_LABELS: Record = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" } const SCORING_LABELS: Record = { 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 { 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 } +// 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 --------------------------------------------------------------- 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) try { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ team_side: teamSide, team_roster_id: teamRosterId, tee_id: teeId }), + 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}`) const created: ApiParticipant = await res.json() @@ -375,7 +413,8 @@ export function SessionBlindDraw({ roster={rosters[team.id] ?? []} tees={tees} matches={sortedMatches} - perSide={perSide} + perSide={maxSlotsPerSide(session.format)} + format={session.format} locked={session.locked_team_ids.includes(team.id)} usedPlayerIds={usedPlayerIds} onAddParticipant={addParticipant} @@ -467,6 +506,7 @@ function TeamColumn({ tees, matches, perSide, + format, locked, usedPlayerIds, onAddParticipant, @@ -484,9 +524,16 @@ function TeamColumn({ tees: ApiTee[] matches: ApiMatch[] perSide: number + format: Format locked: boolean usedPlayerIds: (teamId: string, exceptMatchId: string) => Set - 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 onAddMatch: () => void complete: boolean @@ -545,6 +592,11 @@ function TeamColumn({ > {p.player_name}
+ {format === "money_ball" && p.lineup_order !== null && ( + + {p.lineup_order} + + )} {p.tee_name} @@ -567,7 +619,15 @@ function TeamColumn({ roster={roster} tees={tees} 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,68 +696,95 @@ function AddSlotForm({ roster, tees, disabledPlayerIds, + availableLineupOrders, onAdd, }: { roster: ApiRosterEntry[] tees: ApiTee[] disabledPlayerIds: Set - 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 [teeId, setTeeId] = useState("") + const [lineupOrder, setLineupOrder] = useState("") + + const needsLineupOrder = availableLineupOrders !== null + const canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "") function handleAdd() { - if (!rosterId || !teeId) return - onAdd(rosterId, teeId) + if (!canAdd) return + onAdd(rosterId, teeId, needsLineupOrder ? Number(lineupOrder) : null) setRosterId("") setTeeId("") + setLineupOrder("") } return ( -
- + - + +
+ {needsLineupOrder && ( + + )}
) } diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx index 80e2b19..6ff7d1b 100644 --- a/frontend/components/session-scorecard.tsx +++ b/frontend/components/session-scorecard.tsx @@ -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) -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 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 = { foursome: "Foursome", @@ -32,7 +46,16 @@ const FORMAT_LABELS: Record = { scramble_4: "Scramble (4)", fourball: "Fourball", 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 } @@ -618,6 +641,9 @@ export function SessionScorecard({ units={units} scoringMode={session.scoring_mode} holeConfig={session.hole_config} + format={session.format} + organizationId={organizationId} + matchId={matchId} /> ) : ( <> @@ -657,6 +683,13 @@ export function SessionScorecard({
+ {/* 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" && ( + + )} +