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