Spillerens per-hull-historikk på tvers av alle runder/turneringer (ADR-072)

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-15 06:51:51 +02:00
parent 60ee6a7d16
commit bb087b4437
9 changed files with 975 additions and 2 deletions

View file

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

View file

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

321
app/hole_history.py Normal file
View file

@ -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, tvers av BÅDE frittstående
runder OG org-turneringer ( 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
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 ser ).
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 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
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)

View file

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

View file

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

View file

@ -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
</section>
)
}
// --- 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<HoleHistory | null | undefined>(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 (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
className="flex items-center justify-between gap-3 text-left"
>
<span className="flex flex-col gap-1">
<span className="text-sm font-bold text-foreground">Din historikk dette hullet</span>
<span className="text-xs leading-relaxed text-muted-foreground text-pretty">
Spilt {history.times_played} {history.times_played === 1 ? "gang" : "ganger"} før
{" "}({history.times_personal} frittstående, {history.times_tournament} turnering) -- snitt{" "}
{history.average_score.toFixed(1)} slag ({parSign}
{history.average_score_vs_par.toFixed(1)} til par)
{history.gir_percent !== null && `, GIR ${Math.round(history.gir_percent)}%`}
{history.average_putts !== null && `, ${history.average_putts.toFixed(1)} putter i snitt`}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{expanded && (
<ul className="flex flex-col divide-y divide-border border-t border-border pt-2">
{history.instances.map((inst, i) => (
<li key={i} className="flex items-center justify-between gap-3 py-2 text-sm">
<span className="flex min-w-0 flex-col">
<span className="truncate font-semibold text-foreground">{inst.label}</span>
<span className="text-xs text-muted-foreground">
{inst.played_at ?? "Ukjent dato"} · {inst.source === "personal" ? "Frittstående" : "Turnering"}
</span>
</span>
<span className="shrink-0 font-extrabold tabular-nums text-foreground">
{inst.score} slag
{inst.putts !== null ? `, ${inst.putts} putt${inst.putts === 1 ? "" : "er"}` : ""}
</span>
</li>
))}
</ul>
)}
</div>
)
}

View file

@ -46,6 +46,7 @@ import {
import {
ChoiceRow,
DirectionCross,
HoleHistoryPanel,
NumberPicker,
Stepper,
WizardSection,
@ -2280,6 +2281,9 @@ function ScoreTab({
{round && holes && (
<HoleGrid
base={base}
roundId={roundId}
participantId={participantId}
holes={holes}
holeConfig={round.hole_config}
statLevel={roundParticipants.find((rp) => 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<void>
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). */}
<HoleHistoryPanel url={historyUrl} />
<NumberPicker
label="Putter"
value={putts}
@ -3413,6 +3424,9 @@ function HoleStatsSheet({
}
function HoleGrid({
base,
roundId,
participantId,
holes,
holeConfig,
statLevel,
@ -3422,6 +3436,9 @@ function HoleGrid({
onUpdate,
onUpdateFull,
}: {
base: string
roundId: string
participantId: string
holes: ApiHole[]
holeConfig: ApiRound["hole_config"]
statLevel: "strokes_only" | "strokes_and_putts" | "full"
@ -3551,6 +3568,7 @@ function HoleGrid({
statLevel={statLevel}
strokesReceived={strokesReceived(sheetHole)}
ownBagClubs={ownBagClubs}
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
onSave={(patch) => onUpdateFull(sheetHole, patch)}
onClose={() => setSheetHole(null)}
/>

View file

@ -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). */}
<HoleHistoryPanel
url={`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/history`}
/>
{/* "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). */}

256
tests/test_hole_history.py Normal file
View file

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