Update Todos

Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)

Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx

Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx

Bingo Bango Bongo: format-valg + per-hull picker + poengtabell

Flaggturnering: format-valg + nedtelling/resultatvisning

Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)

Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)

High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)

Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)

Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)

Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
This commit is contained in:
Erol Haagenrud 2026-08-01 06:51:53 +02:00
parent 7ae554062c
commit 968ea79184
18 changed files with 1523 additions and 102 deletions

View file

@ -381,7 +381,8 @@
"Bash(python3 test_round_edit_delete.py)",
"Bash(python3 test_round_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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 et
hull ved gjenlasting, ikke bare la brukeren gjette nytt hver gang."""
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
rows = await conn.fetch(
"""
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
bango_participant_id::text AS bango_participant_id,
bongo_participant_id::text AS bongo_participant_id
FROM round_bbb_hole WHERE round_id = $1
""",
round_id,
)
return [BBBHoleOut(**dict(r)) for r in rows]
# ---------------------------------------------------------------------------
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),

View file

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

View file

@ -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)
samme hull.
</span>
</span>
</label>
)}
{playFormat === "shamble" && (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card px-5 py-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">
Beste N av laget teller per hull
</span>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
onClick={() => setShambleBestN(n)}
aria-pressed={shambleBestN === n}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
shambleBestN === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
))}
</div>
</div>
<p className="text-sm text-muted-foreground">
2-4 spillere laget, alle slår ut og velger beste utslag -- deretter spiller alle
egen ball inn. Kan ikke endres etter at runden er opprettet. Legg til medspillere etter
opprettelse.
</p>
</div>
)}
{needsLineupSetup && (
<p className="text-sm text-muted-foreground">
Nøyaktig 4 spillere -- du setter opp hvem som er "money ball" hvilket hull inne i
runden, etter at medspillerne er lagt til.
</p>
)}
{playFormat === "copenhagen" && (
<p className="text-sm text-muted-foreground">
Nøyaktig 3 spillere -- legg til medspillere etter at runden er opprettet.
</p>
)}
{needsSidesSetup && (
<p className="text-sm text-muted-foreground">
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er

View file

@ -80,6 +80,8 @@ type Player = {
// og den format-relative match-play-enheten (ikke samme som courseHandicap).
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" 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 at handicap beregnes for minst én spiller.</p>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{rows.map((r) => (
<li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{r.label}</span>
<span className="flex flex-col items-end">
<span className="text-base font-extrabold tabular-nums text-foreground">
{r.holes_completed} hull
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{r.ran_out
? "gikk tom for slag"
: `${r.strokes_remaining} slag igjen`}
</span>
</span>
</li>
))}
</ul>
)}
</div>
)
}
// Shamble/Money Ball (2026-07-30) -- flatt felt, ETT lag = hele rundens
// deltakersett (ingen motstander innad -- flere lag kobles via
// flight_group_id-leaderboardet). Viser kun lagets samlede sum.
if (round.play_format === "shamble" || round.play_format === "money_ball") {
const teamScore = round.play_format === "shamble" ? result.shamble_team_score : result.money_ball_team_score
return (
<div className="mb-5 flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-5 text-center">
<span className="text-sm font-bold text-foreground">
{FORMAT_LABELS[round.play_format]} -- lagets samlede score
</span>
<span className="text-4xl font-extrabold tabular-nums text-foreground">{teamScore ?? ""}</span>
{teamScore === null && (
<p className="text-xs text-muted-foreground">Venter at alle spillerne registrerer minst ett hull.</p>
)}
</div>
)
}
// High-low-high (2026-07-30) -- literal poengsum (IKKE match-play
// "X UP" -- poeng fordeles 0-2 per hull via to uavhengige duelløer,
// se handicap_engine.py sin high_low_high_points_for_hole). Samme
// territorium-bar-visuelle språk som resten av to-sidede formater,
// men banneret viser "{poeng A}-{poeng B}" i stedet for en "X UP"-tekst.
if (round.play_format === "high_low_high") {
if (!result.ready) {
return (
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
Venter at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises.
</div>
)
}
const pa = result.hlh_points_a ?? 0
const pb = result.hlh_points_b ?? 0
const holesPlayed = result.match_holes_played ?? 0
const identityA = sideIdentity(round, players, "a")
const identityB = sideIdentity(round, players, "b")
const tone: "a" | "b" | "neutral" = pa > pb ? "a" : pb > pa ? "b" : "neutral"
const widthA = pa + pb > 0 ? (pa / (pa + pb)) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return (
<div className="mb-5 flex flex-col gap-2">
<span className="text-sm font-bold text-foreground">High-low-high</span>
<div
className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
<LeadZone identity={identityA} side="a" dominant={tone === "a"} />
</div>
<div className="flex h-full items-center justify-center px-2">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{holesPlayed > 0 ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{pa}-{pb}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
</div>
</div>
</div>
)
}
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
if (!result.ready) {
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" 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 hull VUNNET, ikke faktiske poeng). Literal poengsum
vises i stedet i FormatResultPanel over selve gridet --
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
vises uendret. Formatet er uansett aldri delt-ball, denne
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
<tr className="border-b border-border bg-muted/30 last:border-b-0">
<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 hull VUNNET, ikke faktiske poeng). Literal poengsum
vises i stedet i FormatResultPanel over selve gridet --
per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
vises uendret. Formatet er uansett aldri delt-ball, denne
sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
{showMatchColors && formatResult?.play_format !== "high_low_high" && (
<tr className="border-b border-border bg-muted/30 last:border-b-0">
<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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,11 +19,25 @@ import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py)
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 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.

View file

@ -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" hvilket hull)
tildeles når spillerne legges til matchen.
</p>
)}
</div>
<div className="flex flex-col gap-2">

View file

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