From bcb712810fe0fc6db9b801a42860f199029abc3d Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 24 Aug 2026 09:55:04 +0200 Subject: [PATCH] Hastecommit --- 093_round_session_net_hunt_display.sql | 28 ++++ ARCHITECTURE_DECISIONS.md | 74 ++++++++++ CHANGELOG.md | 64 +++++++++ FEATURE_BACKLOG.md | 48 ++++--- app/routers/rounds.py | 79 ++++++++++- app/routers/tournaments.py | 63 +++++++-- frontend/components/round-detail.tsx | 78 ++++++++++- frontend/components/round-leaderboard.tsx | 41 +++++- .../session-individual-leaderboard.tsx | 16 ++- frontend/components/tournament-program.tsx | 43 ++++++ frontend/tsconfig.tsbuildinfo | 2 +- tests/test_round_net_hunt_display.py | 105 +++++++++++++++ tests/test_session_net_hunt_display.py | 126 ++++++++++++++++++ 13 files changed, 720 insertions(+), 47 deletions(-) create mode 100644 093_round_session_net_hunt_display.sql create mode 100644 tests/test_round_net_hunt_display.py create mode 100644 tests/test_session_net_hunt_display.py diff --git a/093_round_session_net_hunt_display.sql b/093_round_session_net_hunt_display.sql new file mode 100644 index 0000000..95cf543 --- /dev/null +++ b/093_round_session_net_hunt_display.sql @@ -0,0 +1,28 @@ +-- ===================================================================== +-- TeeCup — migrasjon 093 +-- "Jakter Jesper"-visningsmodus (ADR-106) utvidet til frittstående +-- runder OG lagturnering-økter -- del 2, se ARCHITECTURE_DECISIONS.md. +-- ===================================================================== +-- `round.net_display_style`: samme betydning som `tournament.net_ +-- display_style` (migrasjon 092) -- 'standard' (default, uendret +-- gradvis netto-visning) eller 'hunt' (helt spillehandicap trukket +-- fra med én gang, brutto-til-par vist løpende). Kun meningsfullt for +-- play_format='stroke' -- håndheves i app-laget. +-- +-- `session.net_display_style`: samme, men for én økt i en org- +-- LAGturnering (singles/fourball, scoring_mode='stroke'). Lagturneringer +-- har scoring_method=NULL på selve turneringen (ADR-011s to-lags-modell +-- har ikke det begrepet) -- derfor øktnivå, ikke turneringnivå, se +-- ADR-106-tillegget for begrunnelsen (ingen eksisterende innstillings-UI +-- på turneringnivå for lagformatet, økt-editoren fantes allerede). +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE round + ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard' + CHECK (net_display_style IN ('standard', 'hunt')); + +ALTER TABLE session + ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard' + CHECK (net_display_style IN ('standard', 'hunt')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 0611619..21c694e 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -10700,6 +10700,80 @@ etter utrulling. --- +## ADR-107: "Jakter Jesper" del 2 -- frittstående runder og lagturnering- +økt-leaderboard (migrasjon 093, 2026-08-24) + +**Kontekst:** ADR-106 leverte "Jakter Jesper" kun for org-individuelle +`stroke_net`-turneringer, med to punkter bevisst utenfor (se +FEATURE_BACKLOG.md): frittstående runder, og en kolonne på lagturnering- +øktens individuelle leaderboard. Bruker ba om å fullføre begge i denne +runden ("La oss fullføre Jakter Jesper"). + +**Beslutning A -- Del A (frittstående runder): ny kolonne +`round.net_display_style`, samme fail-loudly-mønster som ADR-106, kun +gyldig for `play_format='stroke'`.** `_load_round_out`s +`my_net_score_to_par` og `_build_leaderboard`s `net_score_to_par` bytter +fra `- total_strokes_received` (gradvis) til `- course_handicap_snapshot` +(helt) når hunt er aktiv -- ren swap på backend. Nøkkelforskjell fra +org-individuell: `round-leaderboard.tsx` regner netto-til-par selv, +klientsidig, fra rå `strokes_received` (ikke fra backendens felt) -- +løsningen fikk derfor en ny parallell klientsidig `huntNettoToPar()`- +funksjon i tillegg til backend-swappen, ikke bare en backend-endring. + +**Beslutning B -- Del B (lagturnering-økt): ny kolonne +`session.net_display_style`, PER ØKT, ikke per turnering.** Vurdert og +avvist: gjenbruk av `tournament.net_display_style` (allerede en kolonne) +-- men lagturneringer har `scoring_method=NULL` alltid, og +`tournament-detail.tsx` (lagformat) har ingen motpart til +`individual-tournament-detail.tsx` sin "Resultat og opsjoner"-fane; +`TournamentSettingsCard` importeres ikke der. En turneringnivå-bryter +ville krevd en helt ny innstillings-UI-seksjon; en øktnivå-kolonne +kunne derimot henge rett på den eksisterende `EditSessionForm`. Også mer +presist: en lagturnering kan ha noen singles/fourball-økter i hunt-modus +og andre ikke. Gyldig kun for `format IN ('singles','fourball') AND +scoring_mode='stroke'` (samme betingelse `fetch_individual_leaderboard` +allerede håndhevet ved lesing -- nå også håndhevet proaktivt ved lagring +i `update_session`). `net_score`/`net_to_par` i +`fetch_individual_leaderboard` bytter fra `- strokes_received_total` +til `- match_participant.playing_handicap` når hunt er aktiv, samme +"swap, ikke ny beregning"-filosofi. `total_points`/Stableford-formelen +er uendret og upåvirket i begge deler, som forventet (kun til-par-tallet +endrer presentasjon, poengberegning bryr seg aldri om hvor mye handicap +er "opptjent" på et gitt tidspunkt). + +**Funn under scratch-verifisering: en tredje, tidligere uidentifisert +netto-beregning.** Planen dekket `my_net_score_to_par` (rounds.py) og +`round-leaderboard.tsx`s `nettoToPar`, men rundekortets "Så +langt"-oppsummeringswidget (`ScoreSoFar` i round-detail.tsx) regner sin +egen `nettoTotal` uavhengig, fra `strokes_received` per hull -- helt uten +hunt-bevissthet. Oppdaget live i scratch (viste feil `+15` i stedet for +korrekt `+8` for en 12-hcp-spiller med 20 brutto slag over 5 hull). Fikset +med samme `courseHandicap`/`netDisplayStyle`-props-mønster og re- +verifisert. Nøyaktig den typen glipp fullstack-scratch-disiplinen finnes +for å fange -- planen alene hadde ikke fanget den. + +**Status:** 6 nye pytest-tester (3 for hver del, samme konvergens-bevis- +mønster som ADR-106: løpende tall skiller seg fra standard midt i +runden/økten, konvergerer eksakt ved 18 fullførte hull; validering begge +veier for begge deler) + full regresjonssuite, 204/204 grønt. `tsc +--noEmit` rent. + +Full-stack scratch-verifisert (egen db + to one-off-containere, ekte +innlogging): en frittstående runde (hcp 12) -- rundekort OG leaderboard +viste -12 midt i runden under hunt (5 hull, alle par), konvergerte til +samme -12 ved 18 fullførte hull under standard (hånd-utregnet på +forhånd, matchet eksakt). En lagturnering-singles-økt, identisk oppsett +-- leaderboardet viste net_score=-8/net_to_par=-12 under hunt, -5 under +standard etter tilbakebytting (5 hull, stroke_index 1-5, alle mottar +slag). Bekreftet bryterne kun vises der de gir mening +(`EditRoundPanel` kun for `play_format='stroke'`, `EditSessionForm` kun +for singles/fourball+stroke). Mørk modus + smal mobilskjerm (375px) +bekreftet lesbart for begge deler, inkludert hunt-bannerets kontrast og +tekstbryting. Alle scratch-ressurser ryddet opp (containere, db, rolle, +images). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a2ed3..86d9bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14744,3 +14744,67 @@ Neste steg: eksisterende data endret). `docker compose build teecup_api teecup_frontend && up -d`, begge containere friske, rene logger. `https://teecup.golf/health` 200 etter utrulling. + +167. **"Jakter Jesper" del 2: frittstående runder + lagturnering-økt- + leaderboard (ADR-107), 2026-08-24.** Bruker: "La oss fullføre Jakter + Jesper" -- fullførte begge oppfølgingspunktene som ADR-106 bevisst + lot ligge (se FEATURE_BACKLOG.md). + + **Del A -- frittstående runder:** ny kolonne `round.net_display_style` + (migrasjon 093), kun gyldig for `play_format='stroke'`, samme + fail-loudly-mønster begge veier som ADR-106. `my_net_score_to_par` + (rounds.py) og leaderboardets `net_score_to_par` bytter fra gradvis + (`- total_strokes_received`) til helt (`- course_handicap_snapshot`) + når hunt er aktiv. Nøkkelforskjell fra org-individuell: + `round-leaderboard.tsx` regner netto-til-par klientsidig fra rå + `strokes_received`, ikke fra et backend-felt -- løsningen fikk derfor + en ny parallell `huntNettoToPar()`-funksjon der, i tillegg til + backend-swappen. `EditRoundPanel` fikk en ny bryter (samme + segmenterte-knapp-mønster som `flagMapVisible`), synlig kun for + `play_format==='stroke'`. + + **Del B -- lagturnering-økt-leaderboard:** ny kolonne + `session.net_display_style` (samme migrasjon), PER ØKT ikke per + turnering -- vurdert og avvist: gjenbruk av + `tournament.net_display_style`, men lagturneringer har + `scoring_method=NULL` alltid og `tournament-detail.tsx` har ingen + motpart til `individual-tournament-detail.tsx` sin + "Resultat og opsjoner"-fane (`TournamentSettingsCard` importeres + ikke der). En øktnivå-kolonne kunne i stedet henge rett på den + allerede eksisterende `EditSessionForm` -- mindre nytt UI, og mer + presist (en lagturnering kan ha noen singles/fourball-økter i + hunt-modus og andre ikke). Gyldig for `format IN + ('singles','fourball') AND scoring_mode='stroke'` (samme betingelse + `fetch_individual_leaderboard` allerede håndhevet ved lesing, nå også + håndhevet proaktivt ved lagring i `update_session`). + `net_score`/`net_to_par` i `fetch_individual_leaderboard` bytter fra + gradvis til `- match_participant.playing_handicap` når hunt er + aktiv. `total_points`/Stableford-formelen uendret i begge deler, som + forventet. + + **Funn under scratch-verifisering:** rundekortets "Så langt"-widget + (`ScoreSoFar`, round-detail.tsx) hadde en TREDJE, uavhengig + netto-beregning (fra `strokes_received` per hull) som ikke var + hunt-bevisst -- ikke identifisert i planen på forhånd. Viste feil + `+15` i stedet for korrekt `+8` (12-hcp, 20 brutto slag over 5 hull) + inntil oppdaget live i scratch. Fikset med samme + `courseHandicap`/`netDisplayStyle`-props-mønster og re-verifisert + ("Netto +8" korrekt etter fiks). Fanget nettopp fordi fullstack- + scratch-disiplinen ble fulgt i stedet for å stole på planen alene. + + **Verifisert:** 6 nye pytest-tester (`test_round_net_hunt_ + display.py`, `test_session_net_hunt_display.py`, 3 hver, samme + konvergens-bevis-mønster som ADR-106: løpende tall skiller seg fra + standard midt i runden/økten, konvergerer eksakt ved 18 fullførte + hull) + full regresjonssuite, 204/204 grønt. `tsc --noEmit` rent. + Full-stack scratch (egen db + to one-off-containere, ekte innlogging): + en frittstående runde (hcp 12, 5 hull par) -- rundekort OG leaderboard + viste -12 under hunt, konvergerte til samme -12 ved 18 fullførte hull + under standard, hånd-utregnet på forhånd og matchet eksakt. En + lagturnering-singles-økt, identisk oppsett -- leaderboardet viste + net_score=-8/net_to_par=-12 under hunt, -5 under standard etter + tilbakebytting (5 hull, stroke_index 1-5, alle mottar slag). + Bekreftet bryterne kun vises der de gir mening. Mørk modus + smal + mobilskjerm (375px) bekreftet lesbart for begge deler, inkludert + hunt-bannerets kontrast og tekstbryting. Alle scratch-ressurser + ryddet opp (containere, db, rolle, images). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index cccf62d..e7893ed 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -5048,7 +5048,7 @@ Uavklart, trenger en egen avklaringsrunde FØR bygging: eksakt triggerlogikk, plassering) FØR design/bygging -- ingen beslutninger tatt ennå, kun fanget opp her for å ikke gå tapt. -## Jakter Jesper (visningsmodus for netto-scoring) — ✅ ORG-INDIVIDUELL FERDIG OG LIVE 2026-08-23 (ADR-106), TO OPPFØLGINGER BEVISST UTENFOR DENNE RUNDEN +## Jakter Jesper (visningsmodus for netto-scoring) — ✅ HELT FERDIG OG LIVE 2026-08-24 (ADR-106 + ADR-107) Bruker beskrev en turneringsform (start på -spillehandicap, hullscore brutto mot par løpende, lavest sluttsum vinner, tiebreak på lavest @@ -5057,31 +5057,29 @@ som en VISNINGSMODUS på eksisterende `stroke_net`-scoring (sluttsummen er matematisk identisk med netto-til-par), ikke en ny scoring_method/ turneringsform -- se ADR-106 for hele resonnementet. -**Ferdig, live:** ny `tournament.net_display_style` ('standard'/'hunt', -migrasjon 092) for org-individuelle turneringer, pluss en ny, generelt -tilgjengelig tiebreak-verdi `lowest_playing_hcp`. +**Ferdig, live 2026-08-23 (ADR-106):** ny `tournament.net_display_style` +('standard'/'hunt', migrasjon 092) for org-individuelle turneringer, +pluss en ny, generelt tilgjengelig tiebreak-verdi `lowest_playing_hcp`. -**Bevisst UTENFOR denne runden, bruker bekreftet sekvensiell bygging -("org-individuell først, deretter egne oppfølgingsrunder"):** +**Ferdig, live 2026-08-24 (ADR-107) -- begge tidligere bevisst utsatte +oppfølgere fullført i samme runde:** -1. **Frittstående runder** (`round.play_format`) -- samme - "hunt"-visning for personlige/vennegjeng-runder med netto-scoring. - `round-stats.tsx`/`round-leaderboard.tsx` sin netto-til-par-visning - (`my_net_score_to_par`, `rounds.py`) er det naturlige stedet å - speile `_attach_stroke_play_columns` sin `stroke_val()`-logikk -- - samme partial-vs-full-handicap-prinsipp, men et annet datagrunnlag - (`round_hole`/`round_participant`, ikke `tournament_round_hole`/ - `tournament_round_participant`). Ikke designet i detalj ennå. +1. **Frittstående runder** (`round.net_display_style`, migrasjon 093). + `my_net_score_to_par` (rounds.py) og leaderboardets `net_score_to_par` + bytter fra gradvis (`- total_strokes_received`) til helt (`- + course_handicap_snapshot`) når hunt er aktiv. `round-leaderboard.tsx` + fikk en egen klientsidig `huntNettoToPar()` (regner netto-til-par selv, + ulikt org-individuell). Underveis avdekket og fikset: rundekortets + "Så langt"-widget (`ScoreSoFar`, round-detail.tsx) hadde en tredje, + uavhengig netto-beregning som ikke var hunt-bevisst -- se ADR-107. 2. **Kolonne på lagturnering-øktens individuelle leaderboard** - (`fetch_individual_leaderboard`, `app/routers/tournaments.py`, - singles/fourball-økter i en org-LAG-turnering). Denne visningen har - INGEN `scoring_method`-velger i dag (viser gross/netto/poeng side om - side, fast oppsett) -- en hunt-kolonne ville være additiv der (samme - prinsipp som `IndividualLeaderboardEntry` sitt allerede eksisterende - `net_to_par`-felt), IKKE en ny `scoring_method` for selve - lagturneringen (ADR-011s to-lags-modell har strukturelt ikke det - begrepet -- bekreftet i ADR-106). Ikke designet i detalj ennå. + (`session.net_display_style`, migrasjon 093, PER ØKT ikke per + turnering -- se ADR-107 for begrunnelsen). Gyldig for + `format IN ('singles','fourball') AND scoring_mode='stroke'`. + `fetch_individual_leaderboard` bytter `net_score`/`net_to_par` fra + gradvis til `- match_participant.playing_handicap` når hunt er aktiv. -**Neste steg:** ingen av de to er startet. Ta fatt på dem som egne, -avgrensede runder når/hvis bruker prioriterer dem -- ikke antatt -hastende, kun fanget opp her for å ikke gå tapt. +**Verifisert:** 6 nye pytest-tester (konvergens-bevis-mønster, begge +deler) + full regresjon 204/204 grønt, `tsc --noEmit` rent, full-stack +scratch (desktop+mobil, lys+mørk). Se ADR-107 og CHANGELOG.md #167 for +fullstendig detalj. diff --git a/app/routers/rounds.py b/app/routers/rounds.py index b7afff0..9938e4f 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -1477,6 +1477,10 @@ class RoundOut(BaseModel): allowance_override: dict | None # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). flag_map_visible: bool + # "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- + # KUN meningsfullt for play_format='stroke'. Se my_net_score_to_par + # under for hvordan 'hunt' faktisk endrer beregningen. + net_display_style: str async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: @@ -1486,7 +1490,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling, started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id, bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct, - allowance_override::text AS allowance_override, flag_map_visible + allowance_override::text AS allowance_override, flag_map_visible, net_display_style FROM round WHERE id = $1 """, round_id, @@ -1557,7 +1561,18 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: total_strokes_received = sum( strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None ) - my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received + # "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun + # gyldig for play_format='stroke' (håndhevet ved lagring i + # RoundUpdate/update_round, så en stableford-runde ser aldri + # net_display_style='hunt' her). Trekker fra HELE spille- + # handicapet med én gang i stedet for kun de slagene som er + # "tjent inn" for de spilte hullene -- konvergerer til + # NØYAKTIG samme tall som standard-visningen ved 18 fullførte + # hull (samme bevis som org-individuell, se ADR-106). + if round_row["net_display_style"] == "hunt": + my_net_score_to_par = (my_score_to_par or 0) - target_row["course_handicap_snapshot"] + else: + my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received # Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN. # Gjenbruker _build_format_result uendret (samme sannhetskilde som selve @@ -1612,6 +1627,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: flight_group_id=round_row["flight_group_id"], allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None, flag_map_visible=round_row["flag_map_visible"], + net_display_style=round_row["net_display_style"], ) @@ -2755,6 +2771,12 @@ class RoundUpdate(BaseModel): # (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw); # None = ikke rør, samme konvensjon som resten av modellen her. flag_map_visible: bool | None = None + # "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- + # eier-only, samme "None = ikke rør"-konvensjon. Kun gyldig for + # play_format='stroke' -- håndhevet i update_round (fail-loudly 400 + # for andre formater, samme prinsipp som org-individuell sin + # scoring_method-sjekk). + net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$") @router.patch("/rounds/{round_id}", response_model=RoundOut) @@ -2787,7 +2809,7 @@ async def update_round( async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) round_row = await conn.fetchrow( - "SELECT started_at, completed_at, play_format FROM round WHERE id = $1", round_id + "SELECT started_at, completed_at, play_format, net_display_style FROM round WHERE id = $1", round_id ) is_completed = round_row["completed_at"] is not None @@ -2812,6 +2834,23 @@ async def update_round( ): raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.") + # "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som + # org-individuell sin scoring_method-sjekk (ARCHITECTURE_DECISIONS.md + # ADR-106): 'hunt' gir ingen mening utenfor play_format='stroke'. + # Sjekker den EFFEKTIVE tilstanden ETTER denne PATCH-en (dekker BEGGE + # retninger -- satt til 'hunt' på feil format, ELLER play_format + # byttet bort fra 'stroke' mens 'hunt' henger igjen fra før). + if body.net_display_style is not None or body.play_format is not None: + effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"] + effective_style = ( + body.net_display_style if body.net_display_style is not None else round_row["net_display_style"] + ) + if effective_style == "hunt" and effective_play_format != "stroke": + raise app_error( + 400, "VALIDATION_FAILED", + "Jakter Jesper-visningen krever play_format='stroke'.", + ) + resolved: _ResolvedCourse | None = None participants = None if body.course_source is not None: @@ -2878,6 +2917,10 @@ async def update_round( await conn.execute( "UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible ) + if body.net_display_style is not None: + await conn.execute( + "UPDATE round SET net_display_style = $2 WHERE id = $1", round_id, body.net_display_style + ) if body.flight_group_id is not None: await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id) if body.visible_categories is not None: @@ -5245,19 +5288,30 @@ class LeaderboardEntryOut(BaseModel): # allerede ble hentet for å beregne total_score/net_score_to_par over, # bare også eksponert rått slik at leaderboardet kan vise dem per hull. holes: list[LeaderboardHoleOut] + # "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- rå course + # handicap eksponert slik at frontend (som allerede regner netto-til- + # par selv, klientsidig, fra strokes_received per hull) kan regne ut + # hunt-visningens tall selv (gross-til-par minus HELE handicapet) uten + # en egen backend-utregningsvei. Samme None-betingelse som net_score_ + # to_par over. + course_handicap: int | None class LeaderboardOut(BaseModel): holes_planned: int completed: bool entries: list[LeaderboardEntryOut] + # "Jakter Jesper" (migrasjon 093) -- hvilken visningsmodus runden er + # satt til. Frontend velger nettoToPar vs. huntNettoToPar basert på + # denne, se round-leaderboard.tsx. + net_display_style: str async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut: """Delt mellom den autentiserte GET og den offentlige/venn-gatede /public/rounds-varianten (ADR-036 fase 2).""" round_row = await conn.fetchrow( - "SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id + "SELECT holes_planned, completed_at, net_display_style FROM round WHERE id = $1", round_id ) participant_rows = await conn.fetch( """ @@ -5297,7 +5351,20 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut: total_points = None if total_score is not None and strokes_received_by_hole: strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) - net_score_to_par = score_to_par - strokes_received_total + # "Jakter Jesper" (migrasjon 093) -- kun netto-til-par-tallet + # bytter til hunt-semantikk (helt handicap trukket fra med én + # gang). total_points (Stableford) er UBERØRT uansett -- poeng + # tildeles per hull ut fra DET hullets netto, ingen "hunt"- + # ekvivalent gir mening for en poengsum (ikke en til-par- + # størrelse). `RoundLeaderboardMini` (valueFor/rankEntries) + # leser dette feltet direkte -- får hunt-tallet "gratis" her, + # samme "gjenbruk feltet, ikke egen visningsvei"-mønster som + # org-individuell sine offentlige/utskrift-sider (ADR-106). + net_score_to_par = ( + score_to_par - p["course_handicap_snapshot"] + if round_row["net_display_style"] == "hunt" + else score_to_par - strokes_received_total + ) # Samme stableford-formel som round-scorecard.tsx sin # stablefordPoints(): 2 poeng for netto par, +/-1 per slag, # gulvet på 0. @@ -5320,6 +5387,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut: score_to_par=score_to_par, net_score_to_par=net_score_to_par, total_points=total_points, + course_handicap=p["course_handicap_snapshot"], holes=[ LeaderboardHoleOut( hole_number=h["hole_number"], @@ -5341,6 +5409,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut: holes_planned=round_row["holes_planned"], completed=round_row["completed_at"] is not None, entries=entries, + net_display_style=round_row["net_display_style"], ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 4501a80..45a668d 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -962,6 +962,14 @@ class SessionOut(BaseModel): shamble_best_n: int | None locked_team_ids: list[str] revealed: bool + # "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- KUN + # meningsfullt for format IN ('singles','fourball') OG + # scoring_mode='stroke' (samme betingelse fetch_individual_leaderboard + # allerede håndhever ved lesing). Øktnivå, ikke turneringnivå -- se + # ADR-106-tillegget for hvorfor (lagturneringer har ikke + # scoring_method på selve turneringen, og har ingen tilsvarende + # innstillings-UI på turneringnivå slik individuelle turneringer har). + net_display_style: str def _session_out(row, locked: set[str]) -> SessionOut: @@ -982,6 +990,7 @@ def _session_out(row, locked: set[str]) -> SessionOut: shamble_best_n=row["shamble_best_n"], locked_team_ids=sorted(locked), revealed=len(locked) >= 2, + net_display_style=row["net_display_style"], ) @@ -994,7 +1003,8 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]: SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, - scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n + scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, + net_display_style FROM session WHERE tournament_id = $1 ORDER BY sequence @@ -1056,7 +1066,8 @@ async def create_session( RETURNING id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, - scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n + scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, + net_display_style """, organization_id, tournament_id, @@ -1093,6 +1104,11 @@ class SessionUpdate(BaseModel): points_per_match: float | None = None allowance_override: dict | None = None course_id: str | None = None + # "Jakter Jesper"-visningsmodus (migrasjon 093) -- se SessionOut sin + # kommentar. Gyldigheten mot format/scoring_mode håndheves i + # update_session under (denne modellen kjenner ikke øktens gjeldende + # format/scoring_mode). + net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$") async def _remap_course(conn, session_id: str, new_course_id: str) -> None: @@ -1189,12 +1205,26 @@ async def update_session( async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow( - "SELECT format, course_id::text AS course_id FROM session WHERE id = $1", + "SELECT format, scoring_mode, course_id::text AS course_id FROM session WHERE id = $1", session_id, ) if session is None: raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") + # "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som + # org-individuell (ADR-106). format/scoring_mode kan ALDRI selv + # endres i samme kall (SessionUpdate ekskluderer dem bevisst, se + # modellens docstring), så det er alltid ØKTENS LAGREDE verdi som + # avgjør -- ingen "effektiv verdi etter denne PATCH-en"-vurdering + # nødvendig her, ulikt tournament/round-varianten. + if updates.get("net_display_style") == "hunt" and not ( + session["format"] in ("singles", "fourball") and session["scoring_mode"] == "stroke" + ): + raise app_error( + 400, "VALIDATION_FAILED", + "Jakter Jesper-visningen krever singles/fourball med brutto-/nettoslagspill.", + ) + new_course_id = updates.pop("course_id", None) allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates @@ -1233,7 +1263,8 @@ async def update_session( SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, allowance_override::text AS allowance_override, scoring_mode, - scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n + scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n, + net_display_style FROM session WHERE id = $1 """, session_id, @@ -1903,6 +1934,9 @@ class IndividualLeaderboard(BaseModel): session_id: str format: str entries: list[IndividualLeaderboardEntry] + # "Jakter Jesper" (migrasjon 093) -- frontend viser en forklarende + # banner og vet dermed hvorfor "Netto"-tallene ser dype/uvante ut. + net_display_style: str async def fetch_individual_leaderboard( @@ -1911,7 +1945,8 @@ async def fetch_individual_leaderboard( session = await conn.fetchrow( """ SELECT tournament_id::text AS tournament_id, format, scoring_mode, - hole_config::text AS hole_config, course_id::text AS course_id + hole_config::text AS hole_config, course_id::text AS course_id, + net_display_style FROM session WHERE id = $1 """, session_id, @@ -1980,8 +2015,17 @@ async def fetch_individual_leaderboard( strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played) strokes_by_hole = dict(zip(played, strokes_list)) strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows) - net_score = gross_score - strokes_received_total - net_to_par = gross_to_par - strokes_received_total + # "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun + # net_score/net_to_par bytter til hunt-semantikk (helt + # spillehandicap trukket fra med én gang). total_points + # (Stableford) er UBERØRT -- poeng tildeles per hull ut fra + # DET hullets netto, ingen "hunt"-ekvivalent for en poengsum. + if session["net_display_style"] == "hunt": + net_score = gross_score - r["playing_handicap"] + net_to_par = gross_to_par - r["playing_handicap"] + else: + net_score = gross_score - strokes_received_total + net_to_par = gross_to_par - strokes_received_total # Samme stableford-formel som round-leaderboardets # total_points (rounds.py) -- 2 poeng for netto par, +/-1 # per slag, gulvet på 0. @@ -2011,7 +2055,10 @@ async def fetch_individual_leaderboard( ) ) - return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries) + return IndividualLeaderboard( + session_id=session_id, format=session["format"], entries=entries, + net_display_style=session["net_display_style"], + ) @router.get( diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index f33af94..c10ecc6 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -259,6 +259,9 @@ type ApiRound = { flight_group_id: string | null // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). flag_map_visible: boolean + // "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- + // kun relevant for play_format='stroke'. + net_display_style: "standard" | "hunt" } // Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx @@ -1717,7 +1720,14 @@ export function RoundDetail({ roundId }: { roundId: string }) { løpende til-par, så dette er nå rendyrket "se mer detaljer" -- hører hjemme i administrasjons-fanen, ikke i veien for score-registreringen. */} - {!completed && } + {!completed && ( + + )} )} @@ -2488,7 +2498,20 @@ function formatSignedAvg(n: number): string { return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed } -function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) { +function ScoreSoFar({ + holes, + order, + courseHandicap, + netDisplayStyle = "standard", +}: { + holes: ApiHole[] | undefined + order: number[] + // "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- brukt kun når + // netDisplayStyle='hunt', for å regne ut nettoTotal uten gradvis + // strokes_received-allokering (se under). + courseHandicap?: number | null + netDisplayStyle?: "standard" | "hunt" +}) { const [expanded, setExpanded] = useState(false) if (!holes || holes.length === 0) return null @@ -2508,8 +2531,14 @@ function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: num // det vises rett og slett ikke disse to (f.eks. en gjest uten HCP). const withNetto = playedSoFar.filter((h) => h.strokes_received !== null) const hasNetto = withNetto.length > 0 + // "Jakter Jesper" (migrasjon 093) -- trekker fra HELE course handicap med + // én gang i stedet for gradvis strokes_received-sum. Faller tilbake til + // standard hvis courseHandicap uventet mangler (bør ikke skje -- samme + // deltaker har jo strokes_received satt, som krever en beregnet HCP). const nettoTotal = hasNetto - ? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0) + ? netDisplayStyle === "hunt" && courseHandicap !== null && courseHandicap !== undefined + ? totalStrokes - courseHandicap + : withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0) : null const stablefordTotal = hasNetto ? withNetto.reduce( @@ -4543,6 +4572,9 @@ function EditRoundPanel({ const [visibleCategories, setVisibleCategories] = useState(round.visible_categories) // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible) + // "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- + // kun relevant for play_format='stroke'. + const [netDisplayStyle, setNetDisplayStyle] = useState(round.net_display_style) const [saving, setSaving] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false) const [error, setError] = useState(null) @@ -4559,7 +4591,8 @@ function EditRoundPanel({ playFormat !== currentEditablePlayFormat || visibilityMode !== round.visibility_mode || (visibilityMode === "friends" && !sameCategories) || - flagMapVisible !== round.flag_map_visible + flagMapVisible !== round.flag_map_visible || + netDisplayStyle !== round.net_display_style async function saveMetadata() { setSaving(true) @@ -4580,6 +4613,7 @@ function EditRoundPanel({ if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible + if (netDisplayStyle !== round.net_display_style) body.net_display_style = netDisplayStyle const result = await onPatch(body) if (!result.ok) setError(result.message) setSaving(false) @@ -4770,6 +4804,42 @@ function EditRoundPanel({ )} + {/* "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- + kun relevant for slagspill. Sluttresultatet blir identisk uansett, + bryteren endrer kun hvordan netto-tallet vises LØPENDE. */} + {playFormat === "stroke" && ( +
+ Visningsmodus for netto-score +
+ {( + [ + { value: "standard" as const, label: "Standard" }, + { value: "hunt" as const, label: "Jakter Jesper" }, + ] + ).map((opt) => ( + + ))} +
+

+ Jakter Jesper: alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot par + løpende -- i stedet for at slag "tjenes inn" gradvis gjennom runden. Standard som standard. +

+
+ )} + {!isCompleted && ( <>
diff --git a/frontend/components/round-leaderboard.tsx b/frontend/components/round-leaderboard.tsx index 63cc947..daebfc7 100644 --- a/frontend/components/round-leaderboard.tsx +++ b/frontend/components/round-leaderboard.tsx @@ -22,7 +22,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Trophy } from "lucide-react" +import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Target, Trophy } from "lucide-react" import { cn } from "@/lib/utils" import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result" import { MatchScorecardGrid } from "@/components/round-scorecard" @@ -49,12 +49,16 @@ type ApiLeaderboardEntry = { net_score_to_par: number | null total_points: number | null holes: ApiLeaderboardHole[] + // "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av + // huntNettoToPar() til å regne ut hunt-visningens tall klientsidig. + course_handicap: number | null } type ApiLeaderboard = { holes_planned: number completed: boolean entries: ApiLeaderboardEntry[] + net_display_style: "standard" | "hunt" } type ApiRoundParticipant = { @@ -350,6 +354,10 @@ type IndividualPlayer = { finished?: boolean gross: (number | null)[] holePoints?: (number | null)[] + // "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av + // huntNettoToPar() (i stedet for strokesReceived, som er gradvis/ + // partial-allokert). Samme null-betydning som hcpIndex/strokesReceived. + courseHandicap: number | null } function hasHcp(p: IndividualPlayer): boolean { @@ -385,6 +393,19 @@ function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null { return diff } +// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- trekker fra HELE +// spillehandicapet med én gang i stedet for kun de slagene som er "tjent +// inn" for de spilte hullene (nettoToPar over). Konvergerer til NØYAKTIG +// samme tall som nettoToPar når alle 18 hull er spilt. +function huntNettoToPar(p: IndividualPlayer, holes: Hole[]): number | null { + if (p.courseHandicap === null) return null + let diff = 0 + p.gross.forEach((g, i) => { + if (g !== null) diff += g - holes[i].par + }) + return diff - p.courseHandicap +} + function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null { if (!hasHcp(p)) return null let pts = 0 @@ -404,6 +425,7 @@ function evalMetric( metric: MetricKey, pointsUnit: string, holes: Hole[], + netDisplayStyle: "standard" | "hunt" = "standard", ): { rankable: boolean sortVal: number @@ -416,7 +438,7 @@ function evalMetric( return { rankable: true, sortVal: v, higherWins: false, pill: } } if (metric === "netto") { - const v = nettoToPar(p, holes) + const v = netDisplayStyle === "hunt" ? huntNettoToPar(p, holes) : nettoToPar(p, holes) if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: } return { rankable: true, sortVal: v, higherWins: false, pill: } } @@ -444,6 +466,7 @@ function IndividualBoard({ pointsUnit = "p", holes, commentsHref, + netDisplayStyle = "standard", }: { players: IndividualPlayer[] metrics: MetricKey[] @@ -451,11 +474,13 @@ function IndividualBoard({ pointsUnit?: string holes: Hole[] commentsHref: string + // "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style. + netDisplayStyle?: "standard" | "hunt" }) { const [metric, setMetric] = useState(defaultMetric) const [openId, setOpenId] = useState(null) - const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) })) + const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes, netDisplayStyle) })) const rankable = evaluated.filter((e) => e.rankable) const unranked = evaluated.filter((e) => !e.rankable) rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal)) @@ -1556,6 +1581,7 @@ function buildIndividualPlayers( finished: false, gross, holePoints, + courseHandicap: e.course_handicap, } }) } @@ -1802,6 +1828,14 @@ export function RoundLeaderboard({ return ( <>

Stilling

+ {leaderboard.net_display_style === "hunt" && ( +
+
+ )} ) diff --git a/frontend/components/session-individual-leaderboard.tsx b/frontend/components/session-individual-leaderboard.tsx index b1bc64a..98d570f 100644 --- a/frontend/components/session-individual-leaderboard.tsx +++ b/frontend/components/session-individual-leaderboard.tsx @@ -16,7 +16,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, Trophy } from "lucide-react" +import { ArrowLeft, Target, Trophy } from "lucide-react" import { cn } from "@/lib/utils" type ApiEntry = { @@ -37,6 +37,11 @@ type ApiLeaderboard = { session_id: string format: string entries: ApiEntry[] + // "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- backend har + // allerede byttet net_score/net_to_par til hunt-semantikk når aktiv, + // ingen klientsidig utregning nødvendig her (ulikt round-leaderboard.tsx, + // som regner netto selv). Kun brukt til å vise en forklarende banner. + net_display_style: "standard" | "hunt" } const FORMAT_LABELS: Record = { @@ -252,6 +257,15 @@ export function SessionIndividualLeaderboard({ {FORMAT_LABELS[data.format] ?? data.format}
+ {data.net_display_style === "hunt" && ( +
+
+ )} + {data.entries.length === 0 ? (

Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt. diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 9e5afa6..149e0b5 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -109,6 +109,10 @@ type ApiSession = { start_mode: "consecutive" | "shotgun" locked_team_ids: string[] revealed: boolean + // "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- kun + // meningsfullt for format IN ('singles','fourball') OG + // scoring_mode='stroke'. + net_display_style: "standard" | "hunt" } // --- Norwegian display labels ---------------------------------------------- @@ -878,10 +882,15 @@ function EditSessionForm({ const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode) const [courseId, setCourseId] = useState(session.course_id) const [courseName, setCourseName] = useState(course?.name ?? "") + const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style) const [submitting, setSubmitting] = useState(false) const hasTime = scheduledAt.trim() !== "" const uid = session.id + // "Jakter Jesper" (migrasjon 093) -- samme betingelse + // fetch_individual_leaderboard håndhever ved lesing (tournaments.py). + const supportsNetDisplayStyle = + (session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -899,6 +908,9 @@ function EditSessionForm({ tee_interval_minutes: startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null, course_id: courseId, + ...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style + ? { net_display_style: netDisplayStyle } + : {}), }) setSubmitting(false) } @@ -1047,6 +1059,37 @@ function EditSessionForm({ )} + {supportsNetDisplayStyle && ( +

+ +
+ {( + [ + { value: "standard" as const, label: "Standard" }, + { value: "hunt" as const, label: "Jakter Jesper" }, + ] + ).map((opt) => ( + + ))} +
+

+ Jakter Jesper: alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot + par løpende -- i stedet for at slag "tjenes inn" gradvis gjennom økten. Sluttresultatet + blir identisk uansett. Standard som standard. +

+
+ )} +