From bb087b44375ac208057b73306b399c45991bb5ce Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 15 Aug 2026 06:51:51 +0200 Subject: [PATCH] =?UTF-8?q?Spillerens=20per-hull-historikk=20p=C3=A5=20tve?= =?UTF-8?q?rs=20av=20alle=20runder/turneringer=20(ADR-072)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny delt app/hole_history.py bygger broen mellom frittstående runders og org-turneringers to ulike bane-identitetssystemer (teeoff facility+course-id vs. org-scopet course.external_course_ref, og tilsvarende for GolfAPI) -- custom/håndlagde baner ekskluderes bevisst, ordinære baner telles med fra begge kilder og slås sammen. Turneringssiden håndterer at spilleren kan ha spilt i flere organisasjoner via samme N+1-per-org-mønster /auth/me allerede bruker (player_organizations_for_user, migrasjon 015). To nye GET .../history-endepunkt (rounds.py og individual_tournaments.py) returnerer samme kombinerte historikk uansett hvilken side som spør. Nytt HoleHistoryPanel i hole-stat-inputs.tsx viser et ekspanderbart sammendrag (antall ganger, snitt slag, GIR%, putter) + enkeltinstanser, lagt til i ScoringWizard og det nye HoleStatsSheet (ADR-071). Ingen migrasjon -- ren lesefunksjon oppå migrasjon 075. Se ADR-072/ CHANGELOG for full detalj og verifisering (73 backend-tester, inkl. integrasjonstest som beviser selve bane-broen på tvers av organisasjoner). Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 104 ++++++ CHANGELOG.md | 66 ++++ app/hole_history.py | 321 ++++++++++++++++++ app/routers/individual_tournaments.py | 48 +++ app/routers/rounds.py | 37 ++ frontend/components/hole-stat-inputs.tsx | 118 ++++++- .../individual-tournament-detail.tsx | 18 + frontend/components/round-detail.tsx | 9 + tests/test_hole_history.py | 256 ++++++++++++++ 9 files changed, 975 insertions(+), 2 deletions(-) create mode 100644 app/hole_history.py create mode 100644 tests/test_hole_history.py diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index d619375..6bf4199 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -7111,6 +7111,110 @@ rent. --- +## ADR-072: Spillerens per-hull-historikk på tvers av alle runder/turneringer ("Steg 2") (2026-08-15) + +Foranledning: se ADR-071 -- dette er selve historikk-funksjonen bruker ba +om, bygget oppå statistikkdybde-pariteten ADR-071 leverte. Bekreftet med +bruker (AskUserQuestion): custom/håndlagde baner ekskluderes helt (ingen +pålitelig felles identitet); ordinære (teeoff-/GolfAPI-importerte) baner +telles med fra BÅDE frittstående runder og org-turneringer, slått sammen +til én historikk når det faktisk er samme fysiske bane. + +**Ny delt modul `app/hole_history.py`** (mønster: `app/blind_draw.py`/ +`app/team_authz.py`, ikke db-fri som `handicap_engine.py` siden dette er +datainnhenting, ikke handicap-regler). Kjernen er bane-broen mellom to +helt forskjellige identitetssystemer: +- Teeoff: `round.teeoff_facility_slug`+`teeoff_course_id` (frittstående) + `<->` `course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"` + når `course.source='official'` (org-turnering) -- eksakt samme + streng-format som `import_official_course()` i `courses.py` allerede + bygger den med, verifisert mot kilden før bruk. +- GolfAPI: `personal_course.external_golfapi_course_id` `<->` + `course.external_course_ref` når `course.source='international'` -- + begge lagrer samme rå GolfAPI-ID, uten prefiks. +- Custom (`round.course_source='custom'` med `personal_course.external_ + golfapi_course_id IS NULL`, eller `course.source='custom'`) gir + bevisst `None` fra begge resolve-funksjonene -- kalleren skjuler da + historikk-panelet stille, ingen feilmelding. + +`fetch_personal_hole_history` går via `round_participant.user_id` +(samme presedens som `/rounds/stats/summary`). `fetch_tournament_hole_ +history` må derimot håndtere at spilleren kan ha spilt turnering i +FLERE organisasjoner -- `tournament_round_hole`/`course`/`tournament_ +participant` er alle org-scopet/RLS-beskyttet, så ett enkelt `org_ +connection()`-kall kan aldri dekke alle. Løst med SAMME N+1-per-org- +mønster som `/auth/me` allerede bruker: `player_organizations_for_user()` +(migrasjon 015, en smal `SECURITY DEFINER`-bro som kun eksponerer en +org-ID-liste) gir org-listen trygt via `plain_connection()`, deretter ett +`org_connection(org_id)`-kall per org. Ingen superuser-/bypass-RLS- +snarvei (ufravikelig regel, CLAUDE.md). + +GIR-/fairway-formlene speiler den ETABLERTE `score - putts <= par - 2` +(rounds.py/round-stats.tsx), IKKE skjema-kommentarens avvikende formel +(samme presisering som ADR-071). `summarize_hole_history()` er en ren +funksjon (ingen db) -- regner GIR%/fairway%/snitt-putter KUN over +instanser som faktisk har putts/utslagsretning registrert (en +`strokes_only`-historisk rad bidrar til snitt-slag, men trekkes ikke inn +i GIR%-nevneren), sorterer mest-nylig-først med udaterte turneringsrunder +(`tournament_round.scheduled_at` er nullbar) sist. + +**To tynne endepunkt**, ett i hver router, begge kaller inn i samme +`hole_history_for_user()` og returnerer SAMME kombinerte historikk +uansett hvilken side spørringen kom fra: +- `GET /rounds/{id}/participants/{pid}/holes/{n}/history` (`rounds.py`) +- `GET /orgs/{org}/tournaments/{tid}/rounds/{rid}/participants/{pid}/holes/{n}/history` + (`individual_tournaments.py`) + +Historikken er for DELTAKEREN (`participant_id`), ikke nødvendigvis den +innloggede brukeren -- en lenket medspiller kan se en annens historikk +mens de fører score for flighten, samme tilgang som selve scoringen. +Gjester (`round_participant.user_id`/`player.user_id` er `NULL`) gir +`None` fra endepunktet -- samme "skjul panelet stille"-kontrakt som en +custom bane, ingen feil. + +**Frontend**: nytt `HoleHistoryPanel` i den delte `hole-stat-inputs.tsx` +(egen unntak fra filens ellers rene "ingen datahenting"-regel, siden +begge scoringsflytene trenger nøyaktig samme henting/visning). Henter +selv (`GET .../history`), viser INGENTING når responsen er `null` +(laster ELLER ingen historikk -- ingen synlig forskjell, unngår et +flimrende spinner-perifert-panel). Ekspanderbar: lukket viser ett +sammendrag ("Spilt N ganger før (X frittstående, Y turnering) -- snitt A +slag (+/-par), GIR B%, C putter i snitt"), åpen viser hver enkeltinstans +(dato, kilde, resultat). Lagt til i `ScoringWizard` sitt strokes-steg +(`round-detail.tsx`) og i det nye `HoleStatsSheet` (`individual- +tournament-detail.tsx`, `ADR-071`) -- samme komponent, ulik URL. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i +`test_hole_history.py`: 6 rene enhetstester av `summarize_hole_history` +(GIR%/fairway%/snitt-eksklusjon, sortering), pluss integrasjonstester som +BEVISER selve bane-broen -- samme spiller, samme teeoff-bane spilt både +frittstående OG i en turnering i en ANNEN organisasjon enn spilleren +selv eier noe i, kombinert historikk viser begge; en custom personal_ +course (ingen GolfAPI-kobling) resolve-r til `None`; en gjeste- +turneringsdeltaker (`player.user_id IS NULL`) gir `None`-historikk). +`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch +`teecup_api`-container (port 18006, live-montert kode) + lokal `next +dev` (port 13006): samme spiller, samme teeoff-bane (facility/course-ID +matchende), én frittstående runde-hull + én turneringsrunde-hull i en +ANNEN org -- bekreftet at BEGGE kontekster (ScoringWizard OG +HoleStatsSheet) viser identisk kombinert historikk ("Spilt 2 ganger før +(1 frittstående, 1 turnering) -- snitt 4.5 slag (+0.5 til par), GIR 50%, +1.5 putter i snitt" -- håndregnet og bekreftet korrekt), ekspandert +instansliste viser begge kildene med riktig dato/kilde-merking (udatert +turneringsrunde viste korrekt "Ukjent dato"), og et upsilt hull viste +INGEN panel (stille skjult, ikke en feil). Lys+mørk bekreftet. Scratch- +stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/ +`teecup_frontend` urørt. + +**Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt skjema. + +**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build +teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra +bruker. Begge containere startet rent. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3026669..fd38b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11419,3 +11419,69 @@ Neste steg: som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker. `docker compose build teecup_api teecup_frontend && up -d` -- begge containere startet rent. + +88. **Spillerens per-hull-historikk på tvers av alle runder/turneringer, + "Steg 2" (ADR-072) — 2026-08-15.** Selve historikk-funksjonen bruker + opprinnelig ba om (se #87/ADR-071 for statistikkdybde-forutsetningen). + Bekreftet med bruker: custom baner ekskluderes helt, ordinære + (teeoff-/GolfAPI-importerte) baner telles med fra BÅDE frittstående + runder og org-turneringer, slått sammen. + + Ny delt modul `app/hole_history.py` -- kjernen er bane-broen mellom + to identitetssystemer: teeoff (`round.teeoff_facility_slug`+ + `teeoff_course_id` mot `course.external_course_ref = "facility: + course_id"` når `source='official'`, eksakt samme format som + `import_official_course()` bygger) og GolfAPI (`personal_course. + external_golfapi_course_id` mot samme rå ID i `course.external_ + course_ref` når `source='international'`). Custom gir bevisst `None` + fra begge resolve-funksjonene -- historikk-panelet skjules stille. + + Turneringssiden må håndtere at spilleren kan ha spilt i FLERE + organisasjoner -- `tournament_round_hole` er org-scopet/RLS- + beskyttet. Løst med samme N+1-per-org-mønster som `/auth/me` + allerede bruker: `player_organizations_for_user()` (migrasjon 015, + smal SECURITY DEFINER-bro) gir org-listen trygt, ett `org_ + connection()`-kall per org deretter. Ingen bypass-RLS-snarvei. + + GIR/fairway-formlene speiler den etablerte `score - putts <= par - + 2` (samme presisering som ADR-071, IKKE skjema-kommentarens avvikende + formel). `summarize_hole_history()` er en ren funksjon -- regner + GIR%/fairway%/snitt-putter kun over instanser MED faktisk registrert + data, sorterer mest-nylig-først. + + To tynne endepunkt (ett i `rounds.py`, ett i `individual_ + tournaments.py`) kaller begge inn i samme `hole_history_for_user()` + og returnerer SAMME kombinerte historikk uansett hvilken side + spørringen kom fra. Historikken er for DELTAKEREN, ikke nødvendigvis + innlogget bruker (samme tilgang som selve scoringen) -- gjester + (`user_id IS NULL`) gir `None`, samme kontrakt som en custom bane. + + Frontend: nytt `HoleHistoryPanel` i `hole-stat-inputs.tsx` (henter + selv, viser ingenting ved `null`-respons). Ekspanderbar: lukket + viser ett sammendrag, åpen viser hver enkeltinstans. Lagt til i + `ScoringWizard` (frittstående) og det nye `HoleStatsSheet` + (turnering, ADR-071) -- samme komponent, ulik URL. + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i + `test_hole_history.py`: 6 rene enhetstester av aggregeringsformlene, + pluss integrasjonstester som BEVISER selve bane-broen -- samme + spiller/samme teeoff-bane spilt frittstående OG i en turnering i en + ANNEN organisasjon, kombinert historikk viser begge; custom bane og + gjeste-deltaker gir korrekt `None`). `tsc --noEmit` rent + 45/45 + vitest. Scratch-database + scratch `teecup_api` (port 18006, live- + montert kode) + lokal `next dev` (port 13006): samme spiller/samme + teeoff-bane, ett frittstående hull + ett turneringshull i en ANNEN + org -- bekreftet BEGGE kontekster viser identisk, håndregnet-korrekt + kombinert historikk ("2 ganger, 1 frittstående/1 turnering, snitt 4.5 + slag, GIR 50%, 1.5 putter"), ekspandert instansliste riktig merket, + et uspilt hull viste ingen panel. Lys+mørk bekreftet. Scratch-stacken + fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/ + `teecup_frontend` urørt. + + **Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt + skjema. + + **Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build + teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra + bruker. Begge containere startet rent. diff --git a/app/hole_history.py b/app/hole_history.py new file mode 100644 index 0000000..8da8415 --- /dev/null +++ b/app/hole_history.py @@ -0,0 +1,321 @@ +""" +Spillerens per-hull-historikk (ADR-072, migrasjon 075/ADR-071-tillegg): +"hvordan spiller jeg egentlig dette hullet?" -- aggregerer ALLE tidligere +ganger en spiller har spilt et gitt hull, på tvers av BÅDE frittstående +runder OG org-turneringer (på tvers av ALLE organisasjoner spilleren har +spilt turnering i), begrenset til ordinære (teeoff-/GolfAPI-importerte) +baner. Custom/håndlagde baner har ingen pålitelig felles identitet på +tvers av de to systemene og telles BEVISST ikke med (bekreftet med +bruker via AskUserQuestion). + +Delt mellom rounds.py (frittstående kontekst) og individual_tournaments.py +(turnering-kontekst) -- begge kaller inn hit og får samme KOMBINERTE +historikk uansett hvilken side spørringen kom fra ("alle gangene" betyr +alle gangene, ikke bare de av typen man akkurat nå ser på). + +Bane-bro mellom de to identitetssystemene (se ADR-072 for utledningen): + - Teeoff: round.teeoff_facility_slug + round.teeoff_course_id <-> + course.external_course_ref = f"{facility_slug}:{teeoff_course_id}" + (course.source = 'official') -- eksakt samme streng-format som + import_official_course() i courses.py bygger den med. + - GolfAPI: personal_course.external_golfapi_course_id <-> + course.external_course_ref (course.source = 'international') -- + begge lagrer samme rå golfapi_course_id, uten prefiks. +Custom personal_course (external_golfapi_course_id IS NULL) og custom +course (source = 'custom') har INGEN bro her -- ekskluderes (resolve- +funksjonene returnerer None, kallerne skjuler da historikk-panelet). + +GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE +`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i +020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel -- +se ADR-071/ADR-033 Beslutning B for hvorfor avviket er bevisst). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Literal + +from asyncpg import Connection +from pydantic import BaseModel + +from .db import org_connection, plain_connection + +CourseKey = tuple[Literal["teeoff"], str, str] | tuple[Literal["golfapi"], str] + + +async def resolve_personal_round_course_key(conn: Connection, round_id: str) -> CourseKey | None: + row = await conn.fetchrow( + """ + SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id::text AS personal_course_id + FROM round WHERE id = $1 + """, + round_id, + ) + if row is None: + return None + if row["course_source"] == "teeoff": + return ("teeoff", row["teeoff_facility_slug"], row["teeoff_course_id"]) + golfapi_id = await conn.fetchval( + "SELECT external_golfapi_course_id FROM personal_course WHERE id = $1", + row["personal_course_id"], + ) + if golfapi_id is None: + return None + return ("golfapi", golfapi_id) + + +async def resolve_tournament_round_course_key(conn: Connection, tournament_round_id: str) -> CourseKey | None: + row = await conn.fetchrow( + """ + SELECT c.source, c.external_course_ref + FROM tournament_round tr + JOIN course c ON c.id = tr.course_id + WHERE tr.id = $1 + """, + tournament_round_id, + ) + if row is None or row["source"] == "custom" or row["external_course_ref"] is None: + return None + if row["source"] == "official": + facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":") + return ("teeoff", facility_slug, teeoff_course_id) + return ("golfapi", row["external_course_ref"]) + + +@dataclass +class HoleInstance: + source: Literal["personal", "tournament"] + played_at: date | None + label: str + score: int + par: int + putts: int | None + tee_shot_result: str | None + approach_result: str | None + chip_count: int | None + bunker_shot_count: int | None + penalty_strokes: int | None + first_putt_distance_bucket: str | None + + +async def fetch_personal_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]: + async with plain_connection() as conn: + if key[0] == "teeoff": + _, facility_slug, teeoff_course_id = key + rows = await conn.fetch( + """ + SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result, + rh.approach_result, rh.chip_count, rh.bunker_shot_count, + rh.penalty_strokes, rh.first_putt_distance_bucket + FROM round_hole rh + JOIN round_participant rp ON rp.id = rh.round_participant_id + JOIN round r ON r.id = rp.round_id + WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true + AND rh.score IS NOT NULL + AND r.course_source = 'teeoff' + AND r.teeoff_facility_slug = $3 AND r.teeoff_course_id = $4 + ORDER BY r.played_at DESC + """, + user_id, hole_number, facility_slug, teeoff_course_id, + ) + else: + _, golfapi_id = key + rows = await conn.fetch( + """ + SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result, + rh.approach_result, rh.chip_count, rh.bunker_shot_count, + rh.penalty_strokes, rh.first_putt_distance_bucket + FROM round_hole rh + JOIN round_participant rp ON rp.id = rh.round_participant_id + JOIN round r ON r.id = rp.round_id + JOIN personal_course pc ON pc.id = r.personal_course_id + WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true + AND rh.score IS NOT NULL + AND r.course_source = 'custom' AND pc.external_golfapi_course_id = $3 + ORDER BY r.played_at DESC + """, + user_id, hole_number, golfapi_id, + ) + return [ + HoleInstance( + source="personal", + played_at=r["played_at"], + label=r["name"] or "Runde", + score=r["score"], + par=r["par"], + putts=r["putts"], + tee_shot_result=r["tee_shot_result"], + approach_result=r["approach_result"], + chip_count=r["chip_count"], + bunker_shot_count=r["bunker_shot_count"], + penalty_strokes=r["penalty_strokes"], + first_putt_distance_bucket=r["first_putt_distance_bucket"], + ) + for r in rows + ] + + +async def fetch_tournament_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]: + """N+1-per-org (samme mønster som /auth/me): tournament_round_hole er + org-scopet/RLS-beskyttet, og spilleren kan ha spilt turnering i flere + organisasjoner -- ett enkelt org_connection()-kall kan derfor aldri + dekke alle. `player_organizations_for_user()` (migrasjon 015, SAMME + SECURITY DEFINER-bro /auth/me allerede bruker) gir org-listen trygt + uten en RLS-omgåelse i selve spørringen.""" + if key[0] == "teeoff": + _, facility_slug, teeoff_course_id = key + source_filter = "official" + external_ref = f"{facility_slug}:{teeoff_course_id}" + else: + _, golfapi_id = key + source_filter = "international" + external_ref = golfapi_id + + async with plain_connection() as conn: + org_rows = await conn.fetch( + "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", + user_id, + ) + + instances: list[HoleInstance] = [] + for org_row in org_rows: + async with org_connection(org_row["organization_id"]) as conn: + rows = await conn.fetch( + """ + SELECT t.name AS tournament_name, tr.scheduled_at, + trh.gross_strokes AS score, h.par, trh.putts, trh.tee_shot_result, + trh.approach_result, trh.chip_count, trh.bunker_shot_count, + trh.penalty_strokes, trh.first_putt_distance_bucket + FROM tournament_round_hole trh + JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id + JOIN tournament_round tr ON tr.id = trp.tournament_round_id + JOIN tournament t ON t.id = tr.tournament_id + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id + JOIN player p ON p.id = tp.player_id + JOIN course c ON c.id = tr.course_id + JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number + WHERE p.user_id = $1 AND trh.hole_number = $2 + AND c.source = $3 AND c.external_course_ref = $4 + ORDER BY tr.scheduled_at DESC NULLS LAST + """, + user_id, hole_number, source_filter, external_ref, + ) + for r in rows: + instances.append( + HoleInstance( + source="tournament", + played_at=r["scheduled_at"].date() if r["scheduled_at"] is not None else None, + label=r["tournament_name"] or "Turnering", + score=r["score"], + par=r["par"], + putts=r["putts"], + tee_shot_result=r["tee_shot_result"], + approach_result=r["approach_result"], + chip_count=r["chip_count"], + bunker_shot_count=r["bunker_shot_count"], + penalty_strokes=r["penalty_strokes"], + first_putt_distance_bucket=r["first_putt_distance_bucket"], + ) + ) + return instances + + +class HoleHistoryInstanceOut(BaseModel): + source: Literal["personal", "tournament"] + played_at: date | None + label: str + score: int + par: int + putts: int | None + tee_shot_result: str | None + approach_result: str | None + chip_count: int | None + bunker_shot_count: int | None + penalty_strokes: int | None + first_putt_distance_bucket: str | None + + +class HoleHistoryOut(BaseModel): + times_played: int + times_personal: int + times_tournament: int + average_score: float + average_score_vs_par: float + # None = ingen av instansene har putter registrert (rene strokes_only- + # historikk) -- IKKE det samme som 0%. + gir_percent: float | None + # Kun meningsfullt for par 4/5 -- None hvis ingen slike instanser har + # utslagsretning registrert. + fairway_hit_percent: float | None + average_putts: float | None + best_score: int + worst_score: int + instances: list[HoleHistoryInstanceOut] + + +def summarize_hole_history(instances: list[HoleInstance]) -> HoleHistoryOut | None: + if not instances: + return None + + ordered = sorted(instances, key=lambda i: i.played_at or date.min, reverse=True) + times_played = len(ordered) + times_personal = sum(1 for i in ordered if i.source == "personal") + scores = [i.score for i in ordered] + + gir_rows = [i for i in ordered if i.putts is not None] + fairway_rows = [i for i in ordered if i.par != 3 and i.tee_shot_result is not None] + putts_values = [i.putts for i in ordered if i.putts is not None] + + return HoleHistoryOut( + times_played=times_played, + times_personal=times_personal, + times_tournament=times_played - times_personal, + average_score=sum(scores) / times_played, + average_score_vs_par=sum(i.score - i.par for i in ordered) / times_played, + gir_percent=( + 100 * sum(1 for i in gir_rows if i.score - i.putts <= i.par - 2) / len(gir_rows) + if gir_rows + else None + ), + fairway_hit_percent=( + 100 * sum(1 for i in fairway_rows if i.tee_shot_result == "fairway") / len(fairway_rows) + if fairway_rows + else None + ), + average_putts=sum(putts_values) / len(putts_values) if putts_values else None, + best_score=min(scores), + worst_score=max(scores), + instances=[ + HoleHistoryInstanceOut( + source=i.source, + played_at=i.played_at, + label=i.label, + score=i.score, + par=i.par, + putts=i.putts, + tee_shot_result=i.tee_shot_result, + approach_result=i.approach_result, + chip_count=i.chip_count, + bunker_shot_count=i.bunker_shot_count, + penalty_strokes=i.penalty_strokes, + first_putt_distance_bucket=i.first_putt_distance_bucket, + ) + for i in ordered + ], + ) + + +async def hole_history_for_user(user_id: str, key: CourseKey | None, hole_number: int) -> HoleHistoryOut | None: + """Toppnivå-inngang brukt av begge routerne: løser INGENTING selv (nøkkel + må allerede være resolvet av kalleren, som kjenner sin egen kontekst) -- + henter fra begge kilder og slår sammen. Returnerer None både når nøkkelen + er None (custom bane) OG når spilleren ikke har noen historikk ennå -- + kalleren skiller ikke mellom disse to, begge betyr "ikke vis panelet". + """ + if key is None: + return None + personal = await fetch_personal_hole_history(user_id, key, hole_number) + tournament = await fetch_tournament_hole_history(user_id, key, hole_number) + return summarize_hole_history(personal + tournament) diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 28b97fb..f2b7b11 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -66,6 +66,11 @@ from handicap_engine import ( from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors +from ..hole_history import ( + HoleHistoryOut, + hole_history_for_user, + resolve_tournament_round_course_key, +) from ..team_authz import user_is_own_tournament_participant from .scoring import played_hole_numbers @@ -1059,6 +1064,49 @@ async def update_hole( ) +# --------------------------------------------------------------------------- +# Spillerens per-hull-historikk (ADR-072) -- se app/hole_history.py for hele +# bane-bro-utledningen mellom frittstående runder og org-turneringer. Samme +# lesetilgang som list_round_participant_holes under (ethvert org-medlem, +# ikke self-only slik selve SCORINGEN er) -- aggregerte historikktall +# (snitt/GIR%) er ikke mer sensitivt enn selve scorekortet. +# --------------------------------------------------------------------------- + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/holes/{hole_number}/history", + response_model=HoleHistoryOut | None, +) +async def get_round_participant_hole_history( + tournament_id: str, + round_id: str, + round_participant_id: str, + hole_number: int, + organization_id: str = Depends(get_authorized_org), +) -> HoleHistoryOut | None: + async with org_connection(organization_id) as conn: + exists = await conn.fetchval( + "SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id + ) + if exists is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + target_user_id = await conn.fetchval( + """ + SELECT p.user_id::text + FROM tournament_round_participant trp + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id + JOIN player p ON p.id = tp.player_id + WHERE trp.id = $1 AND trp.tournament_round_id = $2 + """, + round_participant_id, round_id, + ) + if target_user_id is None: + # Gjest (ingen app_user-kobling) -- ingen historikk å finne. + return None + key = await resolve_tournament_round_course_key(conn, round_id) + return await hole_history_for_user(target_user_id, key, hole_number) + + @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes", diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 468f251..a346d5b 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -53,6 +53,11 @@ from ..db import plain_connection from ..email import RoundSummaryHole, send_round_summary_email from ..errors import app_error, translate_db_errors from ..handicap import SIDE_IS_UNIT, parse_allowance_config +from ..hole_history import ( + HoleHistoryOut, + hole_history_for_user, + resolve_personal_round_course_key, +) from ..realtime import broadcast_round_update, live_sockets_for_round from .auth import _hash_secret from .notifications import create_notification @@ -5520,6 +5525,38 @@ async def update_hole( return RoundHoleOut(**dict(row), strokes_received=strokes_received) +# --------------------------------------------------------------------------- +# Spillerens per-hull-historikk (ADR-072) -- "hvordan spiller jeg egentlig +# dette hullet?", aggregert på tvers av ALLE tidligere runder/turneringer +# (se app/hole_history.py for hele bane-bro-utledningen). Historikken er +# for DELTAKEREN som scores (participant_id), ikke nødvendigvis den +# innloggede brukeren -- en lenket medspiller kan se på en annens historikk +# mens de fører score for flighten (samme tilgang som selve scoringen). +# --------------------------------------------------------------------------- + +@router.get( + "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/history", + response_model=HoleHistoryOut | None, +) +async def get_hole_history( + round_id: str, + participant_id: str, + hole_number: int, + user: CurrentUser = Depends(get_current_user), +) -> HoleHistoryOut | None: + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + target_user_id = await conn.fetchval( + "SELECT user_id::text FROM round_participant WHERE id = $1 AND round_id = $2", + participant_id, round_id, + ) + if target_user_id is None: + # Gjest (ingen app_user-kobling) -- ingen historikk å finne. + return None + key = await resolve_personal_round_course_key(conn, round_id) + return await hole_history_for_user(target_user_id, key, hole_number) + + # --------------------------------------------------------------------------- # Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14). # Spilleren markerer nøyaktig posisjon der slagbudsjettet ble brukt opp. diff --git a/frontend/components/hole-stat-inputs.tsx b/frontend/components/hole-stat-inputs.tsx index a4ffcb4..a0ebd2d 100644 --- a/frontend/components/hole-stat-inputs.tsx +++ b/frontend/components/hole-stat-inputs.tsx @@ -5,9 +5,13 @@ // sin ScoringWizard (frittstående runder) 2026-08-15 (ADR-071) for gjenbruk // i org-turneringers nye HoleStatsSheet, som nå har samme statistikkdybde // (migrasjon 075). Ren UI, ingen datahenting/state utover det som sendes inn. +// +// HoleHistoryPanel (ADR-072) er unntaket -- den HENTER selv (GET .../history), +// siden begge scoringsflytene bruker nøyaktig samme henting/visning og det +// ville vært ren duplisering å bygge den to ganger. -import { useState, type ReactNode } from "react" -import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Flag, Minus, Plus, Target } from "lucide-react" +import { useEffect, useState, type ReactNode } from "react" +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronDown, Flag, Minus, Plus, Target } from "lucide-react" import { cn } from "@/lib/utils" export function golfTermForScore(n: number, par: number): string | null { @@ -348,3 +352,113 @@ export function WizardSection({ title, children }: { title: string; children: Re ) } + +// --- Spillerens per-hull-historikk (ADR-072) -------------------------------- +// "Hvordan spiller jeg egentlig dette hullet?" -- aggregert på tvers av ALLE +// tidligere frittstående runder OG turneringsrunder (custom/håndlagde baner +// og gjestespillere er allerede filtrert bort server-side, se +// app/hole_history.py). Panelet skjuler seg selv (viser ingenting) når +// endepunktet svarer null -- ikke en feil, bare "ingen historikk å vise +// ennå/denne banen støtter det ikke". + +type HoleHistoryInstance = { + source: "personal" | "tournament" + played_at: string | null + label: string + score: number + par: number + putts: number | null + tee_shot_result: string | null + approach_result: string | null + chip_count: number | null + bunker_shot_count: number | null + penalty_strokes: number | null + first_putt_distance_bucket: string | null +} + +type HoleHistory = { + times_played: number + times_personal: number + times_tournament: number + average_score: number + average_score_vs_par: number + gir_percent: number | null + fairway_hit_percent: number | null + average_putts: number | null + best_score: number + worst_score: number + instances: HoleHistoryInstance[] +} + +export function HoleHistoryPanel({ url }: { url: string }) { + // undefined = laster ennå, null = ingen historikk å vise (custom bane, + // gjest, eller genuint aldri spilt før). + const [history, setHistory] = useState(undefined) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + let cancelled = false + setHistory(undefined) + setExpanded(false) + fetch(url, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled) setHistory(data) + }) + .catch(() => { + if (!cancelled) setHistory(null) + }) + return () => { + cancelled = true + } + }, [url]) + + if (!history) return null + + const parSign = history.average_score_vs_par >= 0 ? "+" : "" + + return ( +
+ + {expanded && ( +
    + {history.instances.map((inst, i) => ( +
  • + + {inst.label} + + {inst.played_at ?? "Ukjent dato"} · {inst.source === "personal" ? "Frittstående" : "Turnering"} + + + + {inst.score} slag + {inst.putts !== null ? `, ${inst.putts} putt${inst.putts === 1 ? "" : "er"}` : ""} + +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 9a3616d..d0b62b4 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -46,6 +46,7 @@ import { import { ChoiceRow, DirectionCross, + HoleHistoryPanel, NumberPicker, Stepper, WizardSection, @@ -2280,6 +2281,9 @@ function ScoreTab({ {round && holes && ( rp.id === participantId)?.stat_level ?? "strokes_only"} @@ -3238,6 +3242,7 @@ function HoleStatsSheet({ statLevel, strokesReceived, ownBagClubs, + historyUrl, onSave, onClose, }: { @@ -3246,6 +3251,7 @@ function HoleStatsSheet({ statLevel: "strokes_and_putts" | "full" strokesReceived: number | null ownBagClubs: string[] + historyUrl: string onSave: (patch: HolePatch) => Promise onClose: () => void }) { @@ -3325,6 +3331,11 @@ function HoleStatsSheet({ readOnly={false} /> + {/* Spillerens per-hull-historikk (ADR-072) -- skjuler seg selv når + banen er custom/håndlaget eller spilleren ikke har historikk + ennå (se HoleHistoryPanel/app/hole_history.py). */} + + onUpdateFull(sheetHole, patch)} onClose={() => setSheetHole(null)} /> diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 98bdd40..aa97e2a 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -50,6 +50,7 @@ import { ChoiceRow, DirectionCross, golfTermForScore, + HoleHistoryPanel, NumberPicker, Stepper, WizardSection, @@ -2099,6 +2100,14 @@ function ScoringWizard({ onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })} readOnly={readOnly} /> + + {/* Spillerens per-hull-historikk (ADR-072) -- skjuler seg selv + når banen er custom/håndlaget eller spilleren ikke har noen + historikk ennå (se HoleHistoryPanel/app/hole_history.py). */} + + {/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) -- kun for slagspill/Stableford, og kun når serveren faktisk kan regne ut Net Double Bogey (krever kjent strokes_received). */} diff --git a/tests/test_hole_history.py b/tests/test_hole_history.py new file mode 100644 index 0000000..64087a1 --- /dev/null +++ b/tests/test_hole_history.py @@ -0,0 +1,256 @@ +""" +Spillerens per-hull-historikk (ADR-072). To lag med tester: + +1. Rene enhetstester av summarize_hole_history() -- ingen db, bevis at + GIR%/fairway%/gjennomsnitt regnes riktig og hopper over rader uten data. +2. Integrasjonstester mot ekte router-funksjoner (samme mønster som + test_tournament_hole_stats.py/test_concurrency_version_check.py) -- + beviser selve bane-broen: samme fysiske (teeoff-)bane spilt både som + frittstående runde OG i en org-turnering i en ANNEN organisasjon enn + spilleren selv eier noe i, skal telles sammen. Custom-baner og + gjestespillere skal BEVISST ekskluderes (bruker-bekreftet scope). +""" + +import uuid +from datetime import date + +from app.auth import CurrentUser +from app.hole_history import ( + HoleInstance, + hole_history_for_user, + resolve_personal_round_course_key, + summarize_hole_history, +) +from app.routers.individual_tournaments import ( + HoleUpdate as TournamentHoleUpdate, + update_hole as tournament_update_hole, +) +from app.routers.rounds import HoleUpdate as RoundHoleUpdate, get_hole_history, update_hole as round_update_hole +from app.routers.individual_tournaments import get_round_participant_hole_history + +from tests.conftest import ( + add_membership, + create_hole, + create_org, + create_org_hole, + create_course, + create_participant, + create_player, + create_round, + create_tee, + create_tournament, + create_tournament_participant, + create_tournament_round, + create_tournament_round_participant, + create_user, +) + +import app.db as app_db + + +# --------------------------------------------------------------------------- +# 1. summarize_hole_history() -- ren funksjon, ingen db. +# --------------------------------------------------------------------------- + +def _instance(**overrides) -> HoleInstance: + defaults = dict( + source="personal", + played_at=date(2026, 1, 1), + label="Runde", + score=4, + par=4, + putts=None, + tee_shot_result=None, + approach_result=None, + chip_count=None, + bunker_shot_count=None, + penalty_strokes=None, + first_putt_distance_bucket=None, + ) + defaults.update(overrides) + return HoleInstance(**defaults) + + +def test_summarize_empty_returns_none(): + assert summarize_hole_history([]) is None + + +def test_summarize_counts_and_averages(): + instances = [ + _instance(source="personal", score=4, par=4), + _instance(source="tournament", score=6, par=4), + ] + out = summarize_hole_history(instances) + assert out.times_played == 2 + assert out.times_personal == 1 + assert out.times_tournament == 1 + assert out.average_score == 5.0 + assert out.average_score_vs_par == 1.0 + assert out.best_score == 4 + assert out.worst_score == 6 + + +def test_summarize_gir_percent_only_over_rows_with_putts(): + instances = [ + _instance(score=4, par=4, putts=2), # 4-2=2 <= 4-2=2 -> GIR + _instance(score=5, par=4, putts=3), # 5-3=2 <= 2 -> GIR + _instance(score=6, par=4, putts=None), # ingen putter registrert -- ekskludert fra GIR% + ] + out = summarize_hole_history(instances) + assert out.gir_percent == 100.0 + assert out.average_putts == 2.5 # snitt kun over de to MED putter registrert + + +def test_summarize_gir_percent_none_when_no_putts_data_anywhere(): + instances = [_instance(score=4, par=4, putts=None)] + out = summarize_hole_history(instances) + assert out.gir_percent is None + assert out.average_putts is None + + +def test_summarize_fairway_percent_excludes_par_3_and_missing_data(): + instances = [ + _instance(par=4, tee_shot_result="fairway"), + _instance(par=4, tee_shot_result="left"), + _instance(par=3, tee_shot_result="fairway"), # par 3 -- ekskludert + _instance(par=5, tee_shot_result=None), # ingen utslagsdata -- ekskludert + ] + out = summarize_hole_history(instances) + assert out.fairway_hit_percent == 50.0 # 1 av 2 par-4/5-rader MED data + + +def test_summarize_orders_most_recent_first_and_undated_last(): + instances = [ + _instance(played_at=date(2026, 1, 1), score=4), + _instance(played_at=date(2026, 6, 1), score=5), + _instance(played_at=None, score=6), + ] + out = summarize_hole_history(instances) + assert [i.score for i in out.instances] == [5, 4, 6] + + +# --------------------------------------------------------------------------- +# 2. Integrasjon: bane-bro + eksklusjoner. +# --------------------------------------------------------------------------- + +async def _score_personal_hole(round_id, participant_id, user_id, hole_number, score, putts=None): + user = CurrentUser(user_id=user_id) + await round_update_hole( + round_id, participant_id, hole_number, + RoundHoleUpdate(score=score, putts=putts, expected_version=None), + user=user, + ) + + +async def _score_tournament_hole(tournament_id, round_id, rp_id, org_id, user_id, hole_number, score, putts=None): + user = CurrentUser(user_id=user_id) + await tournament_update_hole( + tournament_id, round_id, rp_id, hole_number, + TournamentHoleUpdate(gross_strokes=score, putts=putts, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def test_teeoff_bridge_combines_personal_and_tournament_history(pool): + """Samme spiller, samme fysiske (teeoff-)bane -- én frittstående runde + OG én turneringsrunde i en organisasjon spilleren IKKE eier noe i selv + (kun har en player-rad koblet til app_user der). Historikken må vise + BEGGE, uansett hvilken side spørringen kommer fra.""" + user_id = await create_user() + + # Frittstående runde -- create_round() sin faste teeoff-bane-fixture + # (facility='test-facility', course='test-course'). + async with app_db.plain_connection() as conn: + round_id = await create_round(conn, user_id) + participant_id = await create_participant(conn, round_id, user_id=user_id, is_owner=True) + await create_hole(conn, participant_id, hole_number=7, par=4, stroke_index=9) + await _score_personal_hole(round_id, participant_id, user_id, hole_number=7, score=4, putts=2) + + # Org-turnering på en bane importert med SAMME teeoff-identitet. + org_id = await create_org() + await add_membership(org_id, user_id, role="member") + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'official', external_course_ref = 'test-facility:test-course' WHERE id = $1", + course_id, + ) + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n, par=4 if n != 7 else 4, stroke_index=n) + tee_id = await create_tee(org_id, course_id) + tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id) + player_id = await create_player(org_id, user_id=user_id) + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id) + await _score_tournament_hole(tournament_id, tournament_round_id, rp_id, org_id, user_id, hole_number=7, score=5, putts=1) + + key = ("teeoff", "test-facility", "test-course") + combined = await hole_history_for_user(user_id, key, 7) + assert combined.times_played == 2 + assert combined.times_personal == 1 + assert combined.times_tournament == 1 + assert sorted(i.score for i in combined.instances) == [4, 5] + + # Begge endepunktene skal returnere SAMME kombinerte historikk. + from_personal_endpoint = await get_hole_history( + round_id, participant_id, 7, user=CurrentUser(user_id=user_id) + ) + assert from_personal_endpoint.times_played == 2 + + from_tournament_endpoint = await get_round_participant_hole_history( + tournament_id, tournament_round_id, rp_id, 7, organization_id=org_id + ) + assert from_tournament_endpoint.times_played == 2 + + +async def test_custom_personal_course_is_excluded(pool): + """Håndlaget bane (course_source='custom', ingen GolfAPI-kobling) -- + resolve_personal_round_course_key skal returnere None, IKKE en snublende + match mot noe annet.""" + user_id = await create_user() + async with app_db.plain_connection() as conn: + personal_course_id = str(uuid.uuid4()) + await conn.execute( + "INSERT INTO personal_course (id, created_by_user_id, name) VALUES ($1, $2, 'Hjemmebanen')", + personal_course_id, user_id, + ) + for n in range(1, 19): + await conn.execute( + "INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) " + "VALUES ($1, $2, 4, $3)", + personal_course_id, n, n, + ) + round_id = str(uuid.uuid4()) + await conn.execute( + """ + INSERT INTO round (id, owner_user_id, course_source, personal_course_id, + course_name_snapshot, tee_name_snapshot, played_at) + VALUES ($1, $2, 'custom', $3, 'Hjemmebanen', 'Gul', CURRENT_DATE) + """, + round_id, user_id, personal_course_id, + ) + key = await resolve_personal_round_course_key(conn, round_id) + assert key is None + + +async def test_guest_tournament_participant_has_no_history(pool): + """player.user_id IS NULL (gjest, aldri registrert/lenket) -- historikk- + endepunktet skal svare None (ikke feile), samme "skjul panelet"- + kontrakt som en custom bane.""" + org_id = await create_org() + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n) + tee_id = await create_tee(org_id, course_id) + tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id) + # user_id utelates bevisst -- create_player() sin default er NULL. + player_id = await create_player(org_id) + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id) + + out = await get_round_participant_hole_history( + tournament_id, tournament_round_id, rp_id, 1, organization_id=org_id + ) + assert out is None