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:
Erol Haagenrud 2026-08-01 06:51:53 +02:00
parent 7ae554062c
commit 968ea79184
18 changed files with 1523 additions and 102 deletions

View file

@ -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",

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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}",

View file

@ -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 et
hull ved gjenlasting, ikke bare la brukeren gjette 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),

View file

@ -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 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 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 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>

View file

@ -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)
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 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" 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

View file

@ -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" 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 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 at alle spillerne registrerer 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 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" 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 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, 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 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, 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

View file

@ -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" })

View file

@ -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" })

View file

@ -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 = {

View file

@ -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,68 +696,95 @@ 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">
<label className="flex flex-col gap-1"> <div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<span className="sr-only">Velg spiller</span> <label className="flex flex-col gap-1">
<select <span className="sr-only">Velg spiller</span>
value={rosterId} <select
onChange={(e) => setRosterId(e.target.value)} value={rosterId}
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" onChange={(e) => setRosterId(e.target.value)}
> className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
<option value="">Velg spiller</option> >
{roster <option value="">Velg spiller</option>
.filter((p) => !disabledPlayerIds.has(p.id)) {roster
.map((p) => ( .filter((p) => !disabledPlayerIds.has(p.id))
<option key={p.id} value={p.id}> .map((p) => (
{p.display_name} <option key={p.id} value={p.id}>
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""} {p.display_name}
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="sr-only">Velg tee</span>
<select
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg tee</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option> </option>
))} ))}
</select> </select>
</label> </label>
<label className="flex flex-col gap-1"> <Button
<span className="sr-only">Velg tee</span> type="button"
<select size="icon"
value={teeId} disabled={!canAdd}
onChange={(e) => setTeeId(e.target.value)} onClick={handleAdd}
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" className="h-11 w-11 shrink-0 rounded-lg"
aria-label="Legg til spiller"
> >
<option value="">Velg tee</option> <Plus aria-hidden="true" className="size-4" />
{tees.map((t) => ( </Button>
<option key={t.id} value={t.id}> </div>
{t.name} {needsLineupOrder && (
</option> <label className="flex flex-col gap-1">
))} <span className="text-xs font-semibold text-muted-foreground">Plass i rekkefølgen (Money Ball)</span>
</select> <select
</label> value={lineupOrder}
<Button onChange={(e) => setLineupOrder(e.target.value)}
type="button" 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"
size="icon" >
disabled={!rosterId || !teeId} <option value="">Velg plass</option>
onClick={handleAdd} {availableLineupOrders!.map((n) => (
className="h-11 w-11 shrink-0 rounded-lg" <option key={n} value={n}>
aria-label="Legg til spiller" {n}
> </option>
<Plus aria-hidden="true" className="size-4" /> ))}
</Button> </select>
</label>
)}
</div> </div>
) )
} }

View file

@ -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,14 +702,23 @@ export function SessionScorecard({
</button> </button>
{showSummary && ( {showSummary && (
<div className="border-t border-border"> <div className="border-t border-border">
<TournamentMatchGrid {session.format === "high_low_high" ? (
teams={teams} <HighLowHighPanel
units={units} organizationId={organizationId}
holes={holes} matchId={matchId}
scorecard={scorecard} teams={teams}
scoringMode={session.scoring_mode} refreshKey={scorecard.stroke_entries?.length ?? 0}
holeConfig={session.hole_config} />
/> ) : (
<TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={session.scoring_mode}
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 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,14 +1671,23 @@ 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">
<TournamentMatchGrid {format === "high_low_high" ? (
teams={teams} <HighLowHighPanel
units={units} organizationId={organizationId}
holes={holes} matchId={matchId}
scorecard={scorecard} teams={teams}
scoringMode={scoringMode} refreshKey={scorecard.stroke_entries?.length ?? 0}
holeConfig={holeConfig} />
/> ) : (
<TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={scoringMode}
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.

View file

@ -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" 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">

View file

@ -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