From 9b7ff8e2ef1e501b1745f19e79cc44f6ab0eb695 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 14 Aug 2026 11:12:25 +0200 Subject: [PATCH] Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 --- 072_eclectic_individual_tournament.sql | 24 +++ ARCHITECTURE_DECISIONS.md | 97 +++++++++++ CHANGELOG.md | 55 +++++++ FEATURE_BACKLOG.md | 43 +++-- app/routers/individual_tournaments.py | 138 +++++++++++++++- app/routers/tournaments.py | 8 +- frontend/components/hole-target-distance.tsx | 17 +- .../individual-tournament-detail.tsx | 92 ++++++++++- frontend/components/ny-runde/primitives.tsx | 6 +- frontend/components/target-distance.tsx | 51 ++---- handicap_engine.py | 58 +++++++ test_handicap_engine.py | 65 ++++++++ tests/test_eclectic.py | 151 ++++++++++++++++++ 13 files changed, 729 insertions(+), 76 deletions(-) create mode 100644 072_eclectic_individual_tournament.sql create mode 100644 tests/test_eclectic.py 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 ? ( - <> -