diff --git a/072_eclectic_individual_tournament.sql b/072_eclectic_individual_tournament.sql new file mode 100644 index 0000000..454629f --- /dev/null +++ b/072_eclectic_individual_tournament.sql @@ -0,0 +1,24 @@ +-- ===================================================================== +-- TeeCup — Eclectic (org individuell turnering), migrasjon 072, "Del C" +-- av den tredelte Flaggturnering-utvidelsen (se ARCHITECTURE_DECISIONS.md +-- ADR-066/067 for Del A/B -- Del C er ikke Flaggturnering-relatert, men +-- ble bedt om i samme runde). Samme drop+recreate-mønster som +-- 042/044/046 -- se 046 sin header for full begrunnelse. +-- +-- Ingen nye tabeller/kolonner -- Eclectic er en NY scoring_method-verdi +-- som gjenbruker eksisterende tournament_round_hole (per-hull +-- bruttoslag, allerede lagret av alle formater) og tournament_round_ +-- participant.course_handicap (allerede beregnet for alle scoring_ +-- method-verdier). "Beste resultat per hull på tvers av rundene" regnes +-- ut ved LESING (samme "regn ut ved lesing"-filosofi som resten av +-- individuelle turneringers leaderboard), se +-- app/routers/individual_tournaments.py::_attach_eclectic_totals. +-- ===================================================================== +\set ON_ERROR_STOP on + +ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check; +ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check + CHECK (scoring_method IS NULL OR scoring_method IN ( + 'stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo', 'flag', + 'eclectic_gross', 'eclectic_net', 'eclectic_stableford' + )); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 85da258..08d94f6 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6718,6 +6718,103 @@ teecup_frontend && up -d` -- begge containere startet rent. Del C --- +## ADR-068: Eclectic-format for org-individuelle turneringer ("Del C") — 2026-08-14 + +Tredje og siste del av den tredelte utvidelsen (se ADR-066/067 for Del +A/B -- Del C er ikke Flaggturnering-relatert i seg selv, men bedt om i +samme runde). Eclectic er en "drømmerunde": spillerens BESTE resultat +per hullnummer på tvers av ALLE turneringens runder, summert til én +total. Tre varianter (brutto/netto/Stableford), samme mønster som +`stroke_gross`/`stroke_net`/`stableford` sin eksisterende oppsplitting. + +**Fail-loudly samme-bane-krav:** Eclectic krever at ALLE turneringens +runder spilles på samme bane -- ellers betyr ikke "hull 5" det samme på +tvers av rundene (ulik par/stroke index/fysisk hull), og "beste resultat +per hull" blir meningsløst å sammenligne. Håndhevet ved rundeopprettelse +(`create_round`, individual_tournaments.py): avviser med +`VALIDATION_FAILED` hvis turneringens `scoring_method` starter med +`eclectic_` og den nye rundens `course_id` avviker fra en allerede +eksisterende runde. Samme ADR-019-filosofi som resten av appen (avvis +tydelig, ikke stille feil). + +**Datamodell (migrasjon 072):** INGEN nye tabeller/kolonner -- kun tre +nye verdier i `tournament_scoring_method_check` (samme drop+recreate- +mønster som 042/044/046). Eclectic gjenbruker eksisterende +`tournament_round_hole` (per-hull bruttoslag, allerede lagret av alle +formater) og `tournament_round_participant.course_handicap` (allerede +beregnet for alle scoring_method-verdier) -- alt regnes ut VED LESING, +samme "regn ut ved lesing"-filosofi som resten av individuelle +turneringers leaderboard (Beslutning C). + +**Motor:** ny, ren `eclectic_best_per_hole(values_by_hole, result_type)` +i `handicap_engine.py` -- for hvert hullnummer, plukker BESTE verdi +(lavest for brutto/netto, høyest for Stableford) blant alle registrerte +runde-verdier for det hullet, uavhengig av hvilken runde den kom fra. +Sporer også HVILKEN runde (via en kaller-tildelt `round_index`) hvert +plukk kom fra -- brukt til frontend sin "beste-kilde"-visning. Hull uten +noen registrering utelates (ikke tellet som 0). 5 nye tester, inkl. ett +scenario der beste resultat for ulike hull kommer fra ULIKE runder +(beviser at plukkingen faktisk skjer per hull, ikke "velg beste hele +runde") -- full motor-suite 127/127. + +**API-integrasjon -- BEVISST avvik fra opprinnelig plan.** Planen +foreslo opprinnelig et helt nytt leseendepunkt for Eclectic. Etter å ha +studert `_compute_individual_standings` (den delte funksjonen bak +`individual_leaderboard`, allerede brukt av `stroke_gross`/`stroke_net`/ +`stableford`/`copenhagen`/`bingo_bango_bongo`) viste det seg klart +renere å legge Eclectic til som EN NY GREN i den samme funksjonen -- +`LeaderboardEntry` fikk `eclectic_total`/`eclectic_holes`-felt (alltid +`None`/tom for andre scoring_method-verdier), og en ny +`_attach_eclectic_totals()`-hjelper populerer dem. INGEN nytt +endepunkt -- frontend bruker det samme `GET .../individual-leaderboard` +den allerede kaller for alle andre formater. 5 nye pytest-tester +(gross/net/stableford-utregning, samme-bane-avvisning, bekreftet at +IKKE-eclectic-turneringer fortsatt tillater ulike baner) -- full +backend-suite 55/55. + +**Frontend (individual-tournament-detail.tsx, håndkodet -- filens egen +uttalte konvensjon, se filens header-kommentar).** `SCORING_METHOD_ +LABELS` fikk de tre nye verdiene. `LeaderboardTab` sin flate liste +(samme gren som Københavner/BBB bruker, ikke Augusta-resultattavlen som +er forbeholdt de tre opprinnelige slagspill-variantene) fikk en +utvidbar "vis hull-for-hull"-rad per deltaker -- ny `EclecticHoleTable`- +komponent viser hull/par/verdi/"Fra runde N" for nøyaktig de hullene som +faktisk teller, beviser visuelt for spilleren at totalen er satt sammen +på tvers av runder. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (55/55). `.venv/bin/python +test_handicap_engine.py` (127/127, standalone-selvsjekk uten pytest). +`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch +`teecup_api`-container (port 18001, live-mountet kode) + lokal `next +dev` (port 13001): sådde én turnering med to runder på SAMME bane, én +deltaker med hull 1 = 4/7 slag og hull 2 = 6/3 slag på tvers av de to +rundene -- forhåndsberegnet forventet total (4+3=7) stemte nøyaktig med +det viste leaderboardet, og "vis hull-for-hull" viste riktig kilde-runde +per hull. Bekreftet i nettleser: samme-bane-avvisningen ga en synlig, +tydelig feilmelding ved forsøk på å opprette en runde på en annen bane. +Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned etterpå -- +ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt. + +**Samme økt, tre små ikke-relaterte UI-rettelser** (brukerrapportert via +skjermbilder, rettet opportunistisk før Del C): avstandsindikatoren +(`target-distance.tsx`) brukte "grønn" og "Midt" i stedet for de riktige +golf-uttrykkene "green"/"senter" -- rettet, samt fjernet "Oppdateres +live"-badgen (og det nå ubrukte `isLive`-sporet i +`hole-target-distance.tsx`) helt, etter brukerønske. "Ny runde"- +veiviserens "Antall hull"-bryter (`Segmented`-primitiven i +`components/ny-runde/primitives.tsx`) hadde en avvikende valgt-stil +(hvit/skygge) sammenlignet med resten av samme skjerm sine knappegrupper +(`ChoiceCard`/`ToggleButton`, grønn aksent-stil) -- rettet til samme +`border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]`-mønster, +gjelder alle `Segmented`-instanser appen-vidt (delt primitiv). + +**IKKE rullet ut ennå** -- migrasjon 072 venter på eksplisitt +bekreftelse fra bruker. Dette var siste del av den tredelte +Flaggturnering-utvidelsen (Del A/B/C alle bygget). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index c348b2f..2c3f28b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11177,3 +11177,58 @@ Neste steg: nye kolonner verifisert direkte i skjemaet. `docker compose build teecup_api teecup_frontend && up -d` -- begge containere startet rent. Del C (Eclectic) gjenstår. + +84. **Eclectic-format for org-individuelle turneringer "Del C" (ADR-068) + + tre ikke-relaterte UI-rettelser — 2026-08-14.** Siste del av den + tredelte utvidelsen (se punkt 82/83, ADR-066/067 for Del A/B). + Eclectic: spillerens beste resultat PER HULL på tvers av ALLE + turneringens runder, summert -- brutto/netto/Stableford-varianter. + Krever samme bane for alle runder (avvises tydelig ved + rundeopprettelse ellers, ADR-019-filosofi). + + Migrasjon 072: kun tre nye verdier i + `tournament_scoring_method_check` -- ingen nye tabeller/kolonner, + Eclectic regnes ut ved lesing fra eksisterende `tournament_round_ + hole`/`course_handicap`. Motor: ny `eclectic_best_per_hole()` i + handicap_engine.py, 5 nye tester (inkl. ett scenario som beviser + at beste-per-hull faktisk plukkes hullvis, ikke "beste hele + runde") -- full motor-suite 127/127. + + **Bevisst avvik fra opprinnelig plan:** planla først et helt nytt + leseendepunkt, men fant at Eclectic passer renere inn som en ny + gren i den ALLEREDE eksisterende `_compute_individual_standings` + (samme funksjon bak `individual_leaderboard`, brukt av alle andre + scoring_method-verdier) -- INGEN nytt endepunkt bygget, frontend + gjenbruker det samme kallet. 5 nye pytest-tester -- full + backend-suite 55/55. + + Frontend (individual-tournament-detail.tsx, håndkodet -- filens + egen konvensjon): ny utvidbar "vis hull-for-hull"-rad per deltaker + i leaderboardet (`EclecticHoleTable`), viser hull/par/verdi/ + kilde-runde -- beviser visuelt at totalen er satt sammen på tvers + av runder. + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh` (55/55) + standalone + `test_handicap_engine.py` (127/127). `tsc --noEmit` rent + 45/45 + vitest. Egen scratch-database + scratch `teecup_api`-container + (port 18001, live-mountet kode) + lokal `next dev` (port 13001): + to runder samme bane, hull-for-hull-forhåndsberegnet total (7) + stemte nøyaktig med visningen, samme-bane-avvisningen ga tydelig + feilmelding ved forsøk på annen bane. Lys+mørk bekreftet. + Scratch-stacken revet ned -- ekte `teecup_db`/`teecup_api`/ + `teecup_frontend` urørt. + + **Samme runde, tre små UI-rettelser** (brukerrapportert via + skjermbilder, ikke Eclectic-relatert): avstandsindikatoren + (`target-distance.tsx`) rettet fra "grønn"/"Midt" til de riktige + golf-uttrykkene "green"/"senter", "Oppdateres live"-badgen (og det + nå ubrukte `isLive`-sporet) fjernet helt. "Antall hull"-bryteren i + Ny runde-veiviseren (`Segmented`-primitiven) hadde en avvikende + hvit valgt-stil sammenlignet med resten av samme skjerm -- rettet + til samme grønne aksent-stil som `ChoiceCard`/`ToggleButton`, + gjelder alle `Segmented`-instanser appen-vidt. + + **IKKE rullet ut ennå** -- migrasjon 072 venter på eksplisitt + bekreftelse fra bruker. Dette var siste del av den tredelte + Flaggturnering-utvidelsen. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 3099380..c67fea5 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -576,8 +576,9 @@ ADR-064 rangefinder til grønn). **✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14), - "Del A" av en tredelt utvidelse** (kartoversikt "Del B" og nytt format - Eclectic "Del C" gjenstår, se egne punkter): "Plant flagget"-knapp + + "Del A" av en tredelt utvidelse** (kartoversikt "Del B"/ADR-067 og + nytt format Eclectic "Del C"/ADR-068 er OGSÅ bygget nå, se egne + punkter -- alle tre deler ferdige): "Plant flagget"-knapp + guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt. "Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG @@ -590,11 +591,21 @@ **✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på- bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg - på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. Kun - Eclectic "Del C" gjenstår nå. Org-individuelle turneringers offentlige - tilskuervisning er BEVISST utenfor omfang her (finnes ikke i det hele - tatt ennå — `/t/[id]/live` er lagturnerings-only — egen, større - oppgave hvis/når etterspurt). Migrasjon 071 — se ADR-067. + på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. + Org-individuelle turneringers offentlige tilskuervisning er BEVISST + utenfor omfang her (finnes ikke i det hele tatt ennå — `/t/[id]/live` + er lagturnerings-only — egen, større oppgave hvis/når etterspurt). + Migrasjon 071 — se ADR-067. + + **✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del, + hele den tredelte utvidelsen er dermed ferdig.** Nytt format for + org-individuelle turneringer (`scoring_method = eclectic_gross/ + eclectic_net/eclectic_stableford`) -- beste resultat PER HULL på + tvers av turneringens EGNE runder (krever samme bane, avvist tydelig + ellers). IKKE det samme som OOM sin ennå-ubygde "Eclectic på tvers av + lenkede turneringer" (se Order of Merit-seksjonen lenger ned) -- den + motoren kan trolig gjenbrukes derfra senere, men selve OOM- + integrasjonen er fortsatt ugjort. Migrasjon 072 — se ADR-068. - **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på: @@ -4353,12 +4364,18 @@ full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS- isolasjon + ekte nettleser-gjennomgang). **Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):** -- **Eclectic-aggregering** ("drømmerunde" -- beste resultat PER HULL på - tvers av alle lenkede turneringer en spiller deltok i, kun for - resultattype Stableford/brutto/netto). Krever egen per-hull-data- - innsamlingslogikk i leaderboard-endepunktet (ikke bare en ny gren i - `order_of_merit_aggregate`, som kun håndterer sum/snitt) -- egen - design-/verifiseringsrunde. +- **Eclectic-aggregering PÅ TVERS AV LENKEDE TURNERINGER (OOM-nivå)** + ("drømmerunde" -- beste resultat PER HULL på tvers av ALLE lenkede + turneringer en spiller deltok i gjennom sesongen, kun for + resultattype Stableford/brutto/netto). **IKKE det samme som** det + Eclectic-formatet som ble bygget 2026-08-14 (ADR-068, migrasjon + 072) -- det er scoping til RUNDENE INNENFOR ÉN ENKELT turnering + (`tournament.scoring_method = eclectic_*`), ikke på tvers av flere + turneringer i en OOM-sesong. Motoren (`eclectic_best_per_hole()` i + handicap_engine.py) er generell nok til trolig å kunne GJENBRUKES + direkte for OOM-varianten også -- selve per-hull-datainnsamlingen på + tvers av turneringer (ikke bare runder) er fortsatt ubygget og + krever egen design-/verifiseringsrunde. - **Lag-OOM sin faktiske leaderboard-beregning.** Skjema (`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 6db8bf5..6f2eb47 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -46,12 +46,16 @@ from pydantic import BaseModel, Field from handicap_engine import ( allocate_over_played_holes, + allocate_strokes_by_index, bbb_points_for_hole, compute_bbb, compute_copenhagen_detail, course_handicap_raw, + EclecticHoleValue, + eclectic_best_per_hole, flag_result, round_half_up, + stableford_points_for_hole, stableford_total, stroke_play_gross_total, stroke_play_net_total, @@ -114,7 +118,7 @@ async def create_round( ) -> TournamentRound: async with org_connection(organization_id) as conn, translate_db_errors(): tournament = await conn.fetchrow( - "SELECT format_type FROM tournament WHERE id = $1", tournament_id + "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") @@ -122,6 +126,21 @@ async def create_round( raise app_error( 400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer." ) + # Eclectic (ADR-067-tillegget "Del C"): "beste resultat per hull på + # tvers av rundene" krever at hullnumrene faktisk betyr det samme + # (samme par/stroke index/fysiske hull) i alle runder -- fail + # loudly fremfor å stille sammenligne epler og pærer, samme + # ADR-019-filosofi som resten av appen. + if tournament["scoring_method"] is not None and tournament["scoring_method"].startswith("eclectic_"): + existing_course_id = await conn.fetchval( + "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", + tournament_id, + ) + if existing_course_id is not None and existing_course_id != body.course_id: + raise app_error( + 400, "VALIDATION_FAILED", + "Eclectic krever samme bane for alle turneringens runder.", + ) row = await conn.fetchrow( f""" WITH inserted AS ( @@ -1030,6 +1049,13 @@ class RoundCellOut(BaseModel): tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell +class EclecticHoleCellOut(BaseModel): + hole_number: int + par: int + value: int # brutto/nettoslag eller Stableford-poeng, avhengig av scoring_method + round_number: int # HVILKEN runde (sequence) det beste resultatet kom fra ("beste-kilde") + + class LeaderboardEntry(BaseModel): tournament_participant_id: str # Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE @@ -1058,6 +1084,12 @@ class LeaderboardEntry(BaseModel): thru_label: str | None = None total_label: str | None = None rounds: list[RoundCellOut] = [] + # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat + # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet + # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ + # method-verdier. + eclectic_total: int | None = None + eclectic_holes: list[EclecticHoleCellOut] = [] async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]: @@ -1120,6 +1152,12 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) elif method == "bingo_bango_bongo": entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0))) + elif method in ("eclectic_gross", "eclectic_net", "eclectic_stableford"): + await _attach_eclectic_totals(conn, tournament_id, method, entries) + if method == "eclectic_stableford": + entries.sort(key=lambda e: (e.eclectic_total is None, -(e.eclectic_total or 0))) + else: + entries.sort(key=lambda e: (e.eclectic_total is None, e.eclectic_total or 0)) else: entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0)) @@ -1315,6 +1353,104 @@ async def _attach_stroke_play_columns( i = j +# --------------------------------------------------------------------------- +# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- "drømmerunde" satt +# sammen av BESTE resultat per hullnummer på tvers av ALLE turneringens +# runder. Krever samme bane for alle runder (håndhevet ved rundeopprettelse, +# se create_round over) -- hullnummer 5 i runde 1 og hullnummer 5 i runde 2 +# er da GARANTERT samme fysiske hull/par/stroke index, så sammenligningen er +# meningsfull. Motoren (eclectic_best_per_hole) vet ingenting om db/HCP -- +# denne funksjonen forbereder per-hull-per-runde-verdiene (brutto/netto/ +# Stableford-poeng, avhengig av `method`) og mater dem inn. +# --------------------------------------------------------------------------- + +async def _attach_eclectic_totals(conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]) -> None: + course_id = await conn.fetchval( + "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id + ) + if course_id is None: + return + + hole_rows = await conn.fetch( + "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", + course_id, + ) + par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows} + si_by_hole = [r["stroke_index"] for r in hole_rows] # hull 1..18, i rekkefølge + + round_rows = await conn.fetch( + """ + SELECT tr.sequence, + trp.tournament_participant_id::text AS tournament_participant_id, + trp.course_handicap, trh.hole_number, trh.gross_strokes + FROM tournament_round tr + JOIN tournament_round_participant trp ON trp.tournament_round_id = tr.id + JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id + WHERE tr.tournament_id = $1 AND trh.gross_strokes IS NOT NULL + ORDER BY tr.sequence + """, + tournament_id, + ) + + # round_index i motorens forstand = rekkefølgen sequence-verdiene faktisk + # dukker opp i (stabil, sortert) -- brukt kun til å spore "beste-kilde" + # tilbake til et ekte rundenummer (sequence) for visning. + sequence_by_index: dict[int, int] = {} + for r in round_rows: + if r["sequence"] not in sequence_by_index.values(): + sequence_by_index[len(sequence_by_index)] = r["sequence"] + index_by_sequence = {seq: i for i, seq in sequence_by_index.items()} + + # Cache slagfordelingen per (course_handicap) -- samme fordeling for + # enhver rad med samme handicap, uansett hvilken runde/hull den gjelder. + allocation_cache: dict[int, list[int]] = {} + + values_by_participant: dict[str, list[list[EclecticHoleValue]]] = {} + for r in round_rows: + if r["course_handicap"] is None: + continue + pid = r["tournament_participant_id"] + if pid not in values_by_participant: + values_by_participant[pid] = [[] for _ in range(18)] + + strokes_received = 0 + if method != "eclectic_gross": + hcp = r["course_handicap"] + if hcp not in allocation_cache: + allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole) + strokes_received = allocation_cache[hcp][r["hole_number"] - 1] + + if method == "eclectic_gross": + value = r["gross_strokes"] + elif method == "eclectic_net": + value = r["gross_strokes"] - strokes_received + else: + value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received) + + values_by_participant[pid][r["hole_number"] - 1].append( + EclecticHoleValue(round_index=index_by_sequence[r["sequence"]], value=value) + ) + + result_type = "gross" if method == "eclectic_gross" else "net" if method == "eclectic_net" else "stableford" + for e in entries: + values_by_hole = values_by_participant.get(e.tournament_participant_id) + if values_by_hole is None: + continue + result = eclectic_best_per_hole(values_by_hole, result_type) + if not result.holes: + continue + e.eclectic_total = result.total + e.eclectic_holes = [ + EclecticHoleCellOut( + hole_number=h.hole_number, + par=par_by_hole[h.hole_number], + value=h.value, + round_number=sequence_by_index[h.round_index], + ) + for h in result.holes + ] + + # --------------------------------------------------------------------------- # Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning # (ikke summert på tvers av turneringen slik individual_leaderboard gjør diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index fe568d3..cb98e6f 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -47,7 +47,9 @@ class TournamentCreate(BaseModel): # for individual, håndhevet i app-laget når runder/score bygges på den. format_type: str = Field(default="team", pattern="^(team|individual)$") scoring_method: str | None = Field( - default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$" + default=None, + pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|" + "eclectic_gross|eclectic_net|eclectic_stableford)$", ) # KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri # organisator-innstilling (samme som frittstående runders round. @@ -184,7 +186,9 @@ class TournamentUpdate(BaseModel): visibility: str | None = Field(default=None, pattern="^(public|org|participants)$") format_type: str | None = Field(default=None, pattern="^(team|individual)$") scoring_method: str | None = Field( - default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$" + default=None, + pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|" + "eclectic_gross|eclectic_net|eclectic_stableford)$", ) description: str | None = None registration_deadline: datetime | None = None diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx index 336a53e..c2fe564 100644 --- a/frontend/components/hole-target-distance.tsx +++ b/frontend/components/hole-target-distance.tsx @@ -4,8 +4,8 @@ import { useEffect, useRef, useState } from "react" import { TargetDistance } from "@/components/target-distance" import { haversineMeters, type LatLng } from "@/lib/geo" -// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (grønn -// front/midt/bak, hindringer) og kombinerer dem klient-side med spillerens +// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (green +// front/senter/bak, hindringer) og kombinerer dem klient-side med spillerens // LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx // allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) -- // `enableHighAccuracy`, grasiøs degradering ved avslått tillatelse, @@ -56,7 +56,6 @@ export function HoleTargetDistance({ }) { const [points, setPoints] = useState(null) const [livePosition, setLivePosition] = useState(null) - const [isLive, setIsLive] = useState(false) const watchIdRef = useRef(null) useEffect(() => { @@ -86,11 +85,9 @@ export function HoleTargetDistance({ watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }) - setIsLive(true) }, () => { // Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data". - setIsLive(false) }, { enableHighAccuracy: true, maximumAge: 4000 }, ) @@ -105,12 +102,10 @@ export function HoleTargetDistance({ // Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm // låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren - // kommer tilbake. `isLive` settes til false mens pauset, så visningen - // tydelig viser "ikke live" fremfor å late som tallet fortsatt oppdateres. + // kommer tilbake. function handleVisibilityChange() { if (document.hidden) { stopWatch() - setIsLive(false) } else { startWatch() } @@ -133,7 +128,7 @@ export function HoleTargetDistance({ // verdt å vise noe. if (points === null || points.length === 0) return null if (!livePosition) { - return + return } const greenPoints = points.filter((p) => p.poi_type === "green" && p.location) @@ -141,7 +136,7 @@ export function HoleTargetDistance({ const middle = greenPoints.find((p) => p.location === "middle") const back = greenPoints.find((p) => p.location === "back") if (!front || !middle || !back) { - return + return } const distances = { @@ -162,6 +157,6 @@ export function HoleTargetDistance({ } return ( - + ) } diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index a080171..ce3b686 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -129,6 +129,13 @@ type ApiRoundCell = { tone: "under" | "even" | "over" | null } +type ApiEclecticHoleCell = { + hole_number: number + par: number + value: number + round_number: number +} + type ApiLeaderboardEntry = { tournament_participant_id: string player_name: string @@ -148,6 +155,10 @@ type ApiLeaderboardEntry = { thru_label: string | null total_label: string | null rounds: ApiRoundCell[] + // Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for + // scoring_method eclectic_gross/eclectic_net/eclectic_stableford. + eclectic_total: number | null + eclectic_holes: ApiEclecticHoleCell[] } type ApiFlagResult = { @@ -231,8 +242,13 @@ const SCORING_METHOD_LABELS: Record = { copenhagen: "Københavner", bingo_bango_bongo: "Bingo Bango Bongo", flag: "Flaggturnering", + eclectic_gross: "Eclectic (brutto)", + eclectic_net: "Eclectic (netto)", + eclectic_stableford: "Eclectic (Stableford)", } +const ECLECTIC_SCORING_METHODS = new Set(["eclectic_gross", "eclectic_net", "eclectic_stableford"]) + function holeNumbersFor(config: ApiRound["hole_config"]): number[] { if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) @@ -2963,6 +2979,10 @@ function HoleGrid({ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) { const [entries, setEntries] = useState(null) const [error, setError] = useState(null) + // Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning, + // én deltaker utvidet om gangen (samme "trykk for detaljer"-mønster som + // resten av appen, ikke alle utvidet samtidig). + const [expandedEclecticId, setExpandedEclecticId] = useState(null) useEffect(() => { let cancelled = false @@ -2988,6 +3008,9 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou 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 } + if (method === "eclectic_gross") return { label: "slag", value: entry.eclectic_total } + if (method === "eclectic_net") return { label: "netto", value: entry.eclectic_total } + if (method === "eclectic_stableford") return { label: "poeng", value: entry.eclectic_total } return { label: "slag", value: entry.gross_total } } @@ -3075,14 +3098,15 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou } } + const isEclectic = method !== null && ECLECTIC_SCORING_METHODS.has(method) + function renderRow(entry: ApiLeaderboardEntry, rank: number) { const { label, value } = valueFor(entry) const isLeader = rank === 1 && value !== null - return ( -
  • + const canExpand = isEclectic && entry.eclectic_holes.length > 0 + const expanded = expandedEclecticId === entry.tournament_participant_id + const row = ( +
    {entry.player_name} {entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt + {canExpand ? (expanded ? " · skjul hull-for-hull" : " · vis hull-for-hull") : ""}
    @@ -3107,6 +3132,23 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou )} + + ) + return ( +
  • + {canExpand ? ( + + ) : ( + row + )} + {expanded ? : null}
  • ) } @@ -3136,6 +3178,46 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou ) } +// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning: +// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra. +// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det +// beste fra flere runder, ikke bare den beste hele runden. +function EclecticHoleTable({ holes, valueLabel }: { holes: ApiEclecticHoleCell[]; valueLabel: string }) { + const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number) + return ( +
    + + + + + + + + + + + {sorted.map((h) => ( + + + + + + + ))} + +
    + Hull + + Par + + {valueLabel === "poeng" ? "Poeng" : valueLabel === "netto" ? "Netto" : "Slag"} + + Fra runde +
    {h.hole_number}{h.par}{h.value}Runde {h.round_number}
    +
    + ) +} + // --- TeeOff-baneimport (2026-08-04) ----------------------------------------- // Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse // Search (ADR-019) -- kopiert hit i stedet for delt via import, samme diff --git a/frontend/components/ny-runde/primitives.tsx b/frontend/components/ny-runde/primitives.tsx index fd6f599..ed3aa1c 100644 --- a/frontend/components/ny-runde/primitives.tsx +++ b/frontend/components/ny-runde/primitives.tsx @@ -216,10 +216,10 @@ export function Segmented({ aria-checked={active} onClick={() => onChange(o.value)} className={cn( - "min-h-9 flex-1 rounded-md px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]", + "min-h-9 flex-1 rounded-md border px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]", active - ? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm" - : "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]", + ? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]" + : "border-transparent text-[var(--nr-muted)] hover:text-[var(--nr-ink)]", )} > {o.label} diff --git a/frontend/components/target-distance.tsx b/frontend/components/target-distance.tsx index aa0614c..edc106e 100644 --- a/frontend/components/target-distance.tsx +++ b/frontend/components/target-distance.tsx @@ -4,7 +4,7 @@ import { TriangleAlert, Satellite } from "lucide-react" import { cn } from "@/lib/utils" /** - * TargetDistance — ren visningskomponent for gjenstående avstand til grønn. + * TargetDistance — ren visningskomponent for gjenstående avstand til green. * * "Dum" med vilje: den mottar ferdigregnede tall som props. Ingen GPS, * ingen fetch, ingen kart. Følger TeeCups clubhouse-palett (grønn/oransje @@ -20,39 +20,11 @@ export type TargetDistanceProps = { label: string // f.eks. "Bunker (front)" distanceMeters: number } | null - isLive: boolean // true = tallene oppdateres kontinuerlig fra GPS akkurat nå /** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */ size?: "compact" | "full" className?: string } -/** Liten "oppdateres live"-indikator: pulserende prikk + tekst (aldri kun farge). */ -function LiveStatus({ isLive, compact }: { isLive: boolean; compact?: boolean }) { - return ( - - {isLive ? ( - <> -