Bland org-turnering-runder inn i personlig statistikk-dashboard (ADR-105)
Punkt 3 i scoring-paritet-gap-listen: GET /rounds/stats/summary leste tidligere kun frittstående runder. Ny _fetch_tournament_stats_rows() speiler hole_history.py sin N+1-per-org-henting og blander resultatet inn i samme by_round/stat_level_by_round/played_at_by_round-dict-er som personlige rader -- ingen endring i selve aggregeringslogikken (_resolve_stats_window/_build_round_series/_summarize_rounds er allerede generiske). Kilde-antall vises separat i UI (samme presedens som hole_history.py sin times_personal/times_tournament). 5 nye pytest-tester + full regresjon (193/193), tsc rent, full-stack scratch-verifisert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5ef8d155e7
commit
c5b5f441da
8 changed files with 464 additions and 17 deletions
|
|
@ -539,7 +539,10 @@
|
|||
"Bash(python3 -m py_compile tests/test_account_merge.py)",
|
||||
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
|
||||
"Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)",
|
||||
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)"
|
||||
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)",
|
||||
"Bash(TEECUP_API_ORIGIN=\"http://127.0.0.1:8102\" npx next build)",
|
||||
"Bash(cp -r public .next/standalone/public)",
|
||||
"Bash(cp -r .next/static .next/standalone/.next/static)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
|
|
@ -10524,6 +10524,98 @@ Alle scratch-ressurser ryddet opp.
|
|||
|
||||
---
|
||||
|
||||
## ADR-105: Personlig statistikk-dashboard blander inn org-turnering-runder (2026-08-22)
|
||||
|
||||
**Kontekst:** punkt 3 i "Scoring-paritet: frittstående runde vs.
|
||||
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bedt om av bruker rett
|
||||
etter ADR-104. `GET /rounds/stats/summary` (`/my-rounds/stats`)
|
||||
aggregerte tidligere KUN `round_hole`/`round_participant` -- en spillers
|
||||
GIR%/putting/fairway-trender fra org-turneringer vistes aldri der.
|
||||
|
||||
**Beslutning A -- bland tallene, vis kilde-antall separat (bruker-
|
||||
bekreftet via AskUserQuestion).** Org-turnering-hull blandes rett inn i
|
||||
de SAMME trend-graf-/snitt-tallene som frittstående runder (ingen
|
||||
visuell markering i selve grafene, ikke filtrerbart), men
|
||||
`RoundStatsSummary` fikk to nye felt --
|
||||
`rounds_completed_personal`/`rounds_completed_tournament` -- vist som en
|
||||
parentes i "Oversikt"-seksjonens ingress ("N fullførte runder (X
|
||||
frittstående + Y turnering)"). Speiler eksakt presedensen i
|
||||
`app/hole_history.py` sin `times_personal`/`times_tournament` (ADR-072).
|
||||
|
||||
**Beslutning B -- ingen endring i selve aggregeringslogikken.**
|
||||
`_resolve_stats_window`/`_build_round_series`/`_summarize_rounds`
|
||||
(`rounds.py`) er allerede fullt generiske over dict-nøklede runde-data
|
||||
(aksesserer felt via `h["felt"]`, likegyldig om raden kom fra
|
||||
`round_hole` eller `tournament_round_hole`) -- kun datahenting/
|
||||
sammenslåing i selve endepunktet ble utvidet. `_summarize_rounds` fikk
|
||||
ett nytt parameter, `source_by_round`, brukt UTELUKKENDE til å telle de
|
||||
to nye kilde-antall-feltene.
|
||||
|
||||
**Beslutning C -- ny `_fetch_tournament_stats_rows()`, speiler
|
||||
`hole_history.py` sin N+1-per-org-henting.** Samme mønster som
|
||||
`fetch_tournament_hole_history` (`plain_connection()` →
|
||||
`player_organizations_for_user` → `org_connection()` per org), men uten
|
||||
course-key-filtreringen (ikke nødvendig -- stats trenger ikke
|
||||
cross-system bane-identitet, kun ALLE en brukers turnering-runder).
|
||||
Namespacet nøkkel (`f"t:{trp_id}"`) slås inn i de SAMME
|
||||
`by_round`/`stat_level_by_round`/`played_at_by_round`-dict-ene som de
|
||||
personlige radene, ingen egen kodesti for turnering-siden i selve
|
||||
aggregeringen.
|
||||
|
||||
**Beslutning D -- "fullført runde" avgjøres ved radtelling, ikke en
|
||||
kolonne.** `tournament_round` har INGEN `completed_at`-kolonne (bekreftet
|
||||
mot `040_individual_tournaments.sql`). En turneringsrunde regnes som
|
||||
fullført når antall `tournament_round_hole`-rader for deltakeren er lik
|
||||
`played_hole_numbers(hole_config)` sin lengde (9/9/18) -- samme
|
||||
radtellings-prinsipp `individual_tournaments.py` allerede bruker andre
|
||||
steder (flaggturnering-resultatet). En rad uten `tr.scheduled_at`
|
||||
(nullable) ekskluderes helt fra stats (kan ikke plasseres kronologisk
|
||||
eller i et vindu uten dato) -- ikke bare fra sortering slik
|
||||
`hole_history.py` sin `NULLS LAST` gjør, siden vindu-logikken her
|
||||
faktisk krever en dato. `hole.par` (gjeldende, ikke et snapshot) leses
|
||||
via samme join som `hole_history.py` bruker -- akseptert, allerede
|
||||
presedert begrensning.
|
||||
|
||||
**Beslutning E -- sortering må gjøres eksplisitt etter sammenslåing.**
|
||||
`_resolve_stats_window` krever `ordered_rounds` sortert NYEST FØRST.
|
||||
Før denne endringen kunne koden stole på at SQL-ens `ORDER BY
|
||||
r.played_at DESC` bevarte riktig innsettingsrekkefølge i
|
||||
Python-dict-en. Etter sammenslåing av to kilder stemmer ikke det lenger
|
||||
-- `ordered_rounds` sorteres nå eksplisitt (`sorted(..., reverse=True)`)
|
||||
i stedet for å stole på innsettingsrekkefølge.
|
||||
|
||||
**Uttrykkelig utenfor omfang:** ingen ny per-turnering-runde
|
||||
stats-side (`round-stats.tsx`-ekvivalent for én `tournament_round`) --
|
||||
punkt 3 slik gap-listen beskriver det er spesifikt AGGREGAT-dashbordet.
|
||||
Ingen migrasjon -- alle kolonner fantes fra før (migrasjon 075).
|
||||
|
||||
**Status:** 5 nye pytest-tester (blanding av kilder, strokes_only-
|
||||
turneringsrunde ekskludert fra putt-snittet, ufullført turneringsrunde
|
||||
ekskludert, runde uten `scheduled_at` ekskludert, RLS -- runde i en org
|
||||
brukeren ikke er spiller i ekskludert) + full regresjonssuite, 193/193
|
||||
grønt mot en scratch-database. `tsc --noEmit` rent.
|
||||
|
||||
Full-stack scratch-verifisert (egen db + to one-off-containere for
|
||||
teecup_api/teecup_frontend, ekte innlogging, Chrome DevTools MCP) --
|
||||
funnet OG rettet underveis: frontend-imagets `next.config.mjs`
|
||||
`rewrites()` leses ved BUILD-tid for standalone-output (dokumentert i
|
||||
`frontend/Dockerfile`s egen kommentar), IKKE ved container-oppstart --
|
||||
en første one-off frontend-container bygget uten riktig
|
||||
`--build-arg TEECUP_API_ORIGIN` proxyet derfor tre innloggingsforsøk til
|
||||
den EKTE, kjørende `teecup_api` (2×422 ugyldig e-postdomene, 1×401
|
||||
ukjent bruker) før feilen ble oppdaget. Ingen skriving skjedde (begge
|
||||
feilkodene er før/uten DB-skriving, rate-limiteren er kun en in-memory-
|
||||
teller), men containeren ble stanset umiddelbart og brukeren varslet.
|
||||
Rettet ved å bygge frontend-imaget på nytt med riktig
|
||||
`--build-arg TEECUP_API_ORIGIN=http://<scratch-api-container>:8000`.
|
||||
Etter fiksen: bruker med runder i to ULIKE organisasjoner + én
|
||||
frittstående runde -- dashbordet viste riktig "3 fullførte runder (1
|
||||
frittstående + 2 turnering)", riktig blandet snitt/GIR/putt-tall, ingen
|
||||
konsollfeil. Mørk modus + smal mobilskjerm (375px) bekreftet lesbart.
|
||||
Alle scratch-ressurser (db, rolle, containere, images) ryddet opp.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
69
CHANGELOG.md
69
CHANGELOG.md
|
|
@ -14555,3 +14555,72 @@ Neste steg:
|
|||
kun `docker compose build teecup_frontend && up -d`, container
|
||||
frisk, rene logger. `https://teecup.golf/health` 200 etter
|
||||
utrulling.
|
||||
|
||||
163. **Personlig statistikk-dashboard blander inn org-turnering-runder
|
||||
(ADR-105), 2026-08-22 -- punkt 3 i gap-listen, bedt om rett etter
|
||||
ADR-104, med punkt 2 (live push) varslet som neste.** `GET
|
||||
/rounds/stats/summary` (`/my-rounds/stats`) aggregerte tidligere KUN
|
||||
frittstående runder -- en spillers GIR%/putting/fairway-trender fra
|
||||
org-turneringer vistes aldri der.
|
||||
|
||||
**Design-avklaring (AskUserQuestion, bekreftet):** bland tallene inn
|
||||
i de eksisterende trend-/snitt-tallene (ingen visuell markering),
|
||||
men vis kilde-antall separat -- speiler `hole_history.py` (ADR-072)
|
||||
sin `times_personal`/`times_tournament`. Ny `RoundStatsSummary`-felt
|
||||
`rounds_completed_personal`/`rounds_completed_tournament`, vist som
|
||||
"(X frittstående + Y turnering)" i Oversikt-ingressen.
|
||||
|
||||
**Backend:** ny `_fetch_tournament_stats_rows()` i `rounds.py`,
|
||||
speiler `hole_history.py` sin N+1-per-org-henting
|
||||
(`player_organizations_for_user` → `org_connection` per org), uten
|
||||
course-key-filtreringen (ikke nødvendig her). Resultatet slås inn i
|
||||
de SAMME `by_round`/`stat_level_by_round`/`played_at_by_round`-
|
||||
dict-ene som personlige rader (namespacet nøkkel `f"t:{trp_id}"`) --
|
||||
`_resolve_stats_window`/`_build_round_series`/`_summarize_rounds`
|
||||
trengte INGEN endring i selve regnelogikken (kun ett nytt
|
||||
`source_by_round`-parameter til `_summarize_rounds` for de to nye
|
||||
kilde-antall-feltene). `tournament_round` har ingen `completed_at`
|
||||
-- "fullført" avgjøres ved radtelling mot
|
||||
`played_hole_numbers(hole_config)`, samme prinsipp
|
||||
`individual_tournaments.py` bruker for flaggturnering-resultatet.
|
||||
Rader uten `tr.scheduled_at` ekskluderes helt (kan ikke plasseres i
|
||||
et tidsvindu uten dato). `ordered_rounds`-sorteringen måtte gjøres
|
||||
eksplisitt (kunne før stole på SQL-ens `ORDER BY` som
|
||||
innsettingsrekkefølge -- stemmer ikke lenger etter sammenslåing av
|
||||
to kilder).
|
||||
|
||||
**Frontend:** `ApiStatsSummary`-typen fikk de to nye feltene,
|
||||
`rounds-stats-summary.tsx` sin "Aggregert over N fullførte
|
||||
runder"-setning utvidet med kilde-parentesen når
|
||||
`rounds_completed_tournament > 0`. Ingen ny komponent.
|
||||
|
||||
**Fant og rettet underveis i full-stack scratch-verifiseringen:**
|
||||
en første one-off scratch-frontend-container ble bygget UTEN riktig
|
||||
`--build-arg TEECUP_API_ORIGIN` -- `next.config.mjs` sin
|
||||
`rewrites()` leses ved BUILD-tid for standalone-output (dokumentert
|
||||
i `frontend/Dockerfile`s egen kommentar), IKKE ved
|
||||
container-oppstart som antatt. Containeren proxyet derfor tre
|
||||
innloggingsforsøk til den EKTE, kjørende `teecup_api` (2×422 ugyldig
|
||||
e-postdomene, 1×401 ukjent testbruker) før feilen ble oppdaget.
|
||||
Ingen skriving til ekte `teecup_db` skjedde (begge feilkodene
|
||||
inntreffer før/uten DB-skriving; rate-limiteren er en ren
|
||||
in-memory-teller) -- containeren ble stanset umiddelbart og
|
||||
brukeren varslet direkte i økten. Rettet ved å bygge
|
||||
scratch-frontend-imaget på nytt med riktig
|
||||
`--build-arg TEECUP_API_ORIGIN` pekt mot scratch-API-containeren.
|
||||
|
||||
**Verifisert:** 5 nye pytest-tester (kildeblanding,
|
||||
strokes_only-turneringsrunde ekskludert fra putt-snittet, ufullført
|
||||
turneringsrunde ekskludert, runde uten `scheduled_at` ekskludert,
|
||||
RLS -- runde i en org brukeren ikke er spiller i ekskludert) + full
|
||||
regresjonssuite, 193/193 grønt. `tsc --noEmit` rent. Full-stack
|
||||
scratch (egen db + to one-off-containere for
|
||||
teecup_api/teecup_frontend, ekte innlogging) med en bruker som har
|
||||
runder i TO ulike organisasjoner + én frittstående runde: dashbordet
|
||||
viste riktig "3 fullførte runder (1 frittstående + 2 turnering)",
|
||||
riktig blandet snitt/GIR/putt, ingen konsollfeil. Mørk modus + smal
|
||||
mobilskjerm (375px) bekreftet lesbart. Alle scratch-ressurser (db,
|
||||
rolle, containere, images) ryddet opp.
|
||||
|
||||
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse (kun
|
||||
backend-endring denne gangen, i motsetning til ADR-102/103/104).
|
||||
|
|
|
|||
|
|
@ -4959,14 +4959,15 @@ runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
|
|||
kringkaster allerede (`broadcast_live_update` i
|
||||
`individual_tournaments.py`), men kun til den OFFENTLIGE
|
||||
tilskuersiden, ikke den autentiserte. Antatt **liten/middels**.
|
||||
3. **Personlig stats-dashboard ekskluderer turneringsrunder helt.**
|
||||
`/rounds/stats/summary`-spørringen (`rounds.py`) leser KUN
|
||||
`round_participant`/`round_hole` -- `tournament_round_participant`/
|
||||
`tournament_round_hole` er aldri med. En spillers GIR%/putting/
|
||||
fairway-trender fra org-turneringer vises aldri i eget stats-bilde,
|
||||
og org-turneringer har ingen egen tilsvarende endepunkt/side i det
|
||||
hele tatt. Antatt **middels/stor** -- ekte datamodell-brobygging
|
||||
(avstemme `stat_level`-forskjeller), ikke bare gjenbruk av UI.
|
||||
3. ✅ **Personlig stats-dashboard blander nå inn turneringsrunder --
|
||||
FERDIG BYGGET 2026-08-22 (ADR-105), se CHANGELOG punkt 163.**
|
||||
`GET /rounds/stats/summary` henter nå ny `_fetch_tournament_stats_rows()`
|
||||
(speiler `hole_history.py` sin N+1-per-org-henting) og blander inn i
|
||||
de samme trend-/snitt-tallene som frittstående runder, med kilde-
|
||||
antall vist separat ("N fullførte runder (X frittstående + Y
|
||||
turnering)"), samme presedens som `hole_history.py` sin
|
||||
`times_personal`/`times_tournament`. Ikke rullet ut ennå -- venter på
|
||||
brukerens bekreftelse.
|
||||
4. ✅ **Fullt flerspiller-scorekort-grid -- FERDIG BYGGET OG RULLET UT
|
||||
2026-08-22 (ADR-104), se CHANGELOG punkt 162.** Ny `TournamentScorecardGrid`
|
||||
(ekte alle-spillere×alle-hull-matrise, samme struktur som
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ from pydantic import BaseModel, EmailStr, Field
|
|||
from .. import golfapi_cache, golfapi_client, storage, teeoff_client
|
||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||
from ..config import settings
|
||||
from ..db import plain_connection
|
||||
from ..db import org_connection, 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
|
||||
|
|
@ -62,6 +62,7 @@ from ..realtime import broadcast_round_update, live_sockets_for_round
|
|||
from ..target_points import TargetPoint, get_target_points
|
||||
from .auth import _hash_secret
|
||||
from .notifications import create_notification
|
||||
from .scoring import played_hole_numbers
|
||||
from handicap_engine import (
|
||||
HoleResult,
|
||||
PerPlayerPercentage,
|
||||
|
|
@ -1870,6 +1871,12 @@ class RoundStatsSeriesPoint(BaseModel):
|
|||
|
||||
class RoundStatsSummary(BaseModel):
|
||||
rounds_completed: int
|
||||
# Kilde-nedbrytning av `rounds_completed` -- org-turnering-runder
|
||||
# blandes inn i alle tallene under (ADR: se CHANGELOG), men antallet
|
||||
# per kilde vises separat, samme prinsipp som `hole_history.py` sin
|
||||
# `times_personal`/`times_tournament`.
|
||||
rounds_completed_personal: int
|
||||
rounds_completed_tournament: int
|
||||
avg_score_to_par: float | None
|
||||
avg_putts_per_18: float | None
|
||||
putts_tracked_rounds: int
|
||||
|
|
@ -2038,6 +2045,7 @@ def _summarize_rounds(
|
|||
by_round: dict[str, list],
|
||||
stat_level_by_round: dict[str, str],
|
||||
played_at_by_round: dict[str, date],
|
||||
source_by_round: dict[str, str],
|
||||
) -> RoundStatsSummary:
|
||||
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
|
||||
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
|
||||
|
|
@ -2125,6 +2133,8 @@ def _summarize_rounds(
|
|||
|
||||
return RoundStatsSummary(
|
||||
rounds_completed=len(round_ids),
|
||||
rounds_completed_personal=sum(1 for rid in round_ids if source_by_round.get(rid) == "personal"),
|
||||
rounds_completed_tournament=sum(1 for rid in round_ids if source_by_round.get(rid) == "tournament"),
|
||||
avg_score_to_par=avg(to_par_values),
|
||||
avg_putts_per_18=avg(putt_totals),
|
||||
putts_tracked_rounds=len(putt_totals),
|
||||
|
|
@ -2160,6 +2170,83 @@ def _summarize_rounds(
|
|||
)
|
||||
|
||||
|
||||
_TOURNAMENT_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
|
||||
|
||||
|
||||
async def _fetch_tournament_stats_rows(user_id: str) -> list[dict]:
|
||||
"""Speiler `hole_history.py` sin `fetch_tournament_hole_history`
|
||||
N+1-per-org-mønster (`player_organizations_for_user` ->
|
||||
`org_connection` per org), men henter ALLE en brukers turnering-hull
|
||||
på tvers av org-er i stedet for ett hullnummer for én bane.
|
||||
|
||||
`tournament_round` har ingen `completed_at`-kolonne (bekreftet mot
|
||||
040_individual_tournaments.sql) -- "fullført runde" avgjøres i
|
||||
stedet ved rad-telling per `tournament_round_participant_id` mot
|
||||
`played_hole_numbers(hole_config)`, samme prinsipp som
|
||||
`individual_tournaments.py` sin `holes_completed`/flag-resultat
|
||||
bruker andre steder i kodebasen.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
rows: list[dict] = []
|
||||
for org_row in org_rows:
|
||||
async with org_connection(org_row["organization_id"]) as conn:
|
||||
trh_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT trp.id::text AS trp_id, tr.hole_config::text AS hole_config,
|
||||
tp.stat_level, tr.scheduled_at,
|
||||
h.par, trh.gross_strokes AS score, trh.putts,
|
||||
trh.tee_shot_result, trh.approach_result, trh.chip_count,
|
||||
trh.bunker_shot_count, trh.penalty_strokes, trh.anyway_strokes
|
||||
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_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
|
||||
WHERE p.user_id = $1
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
|
||||
by_trp: dict[str, list] = {}
|
||||
for r in trh_rows:
|
||||
by_trp.setdefault(r["trp_id"], []).append(r)
|
||||
|
||||
for trp_id, holes in by_trp.items():
|
||||
hole_config = holes[0]["hole_config"]
|
||||
expected = _TOURNAMENT_HOLE_COUNT_BY_CONFIG.get(hole_config, 18)
|
||||
if len(holes) < expected:
|
||||
continue # runden er ikke fullført ennå
|
||||
scheduled_at = holes[0]["scheduled_at"]
|
||||
if scheduled_at is None:
|
||||
continue # kan ikke plasseres kronologisk uten dato
|
||||
for h in holes:
|
||||
rows.append(
|
||||
{
|
||||
"round_id": f"t:{trp_id}",
|
||||
"stat_level": h["stat_level"],
|
||||
"played_at": scheduled_at.date(),
|
||||
"source": "tournament",
|
||||
"par": h["par"],
|
||||
"played": True,
|
||||
"score": h["score"],
|
||||
"putts": h["putts"],
|
||||
"tee_shot_result": h["tee_shot_result"],
|
||||
"approach_result": h["approach_result"],
|
||||
"chip_count": h["chip_count"],
|
||||
"bunker_shot_count": h["bunker_shot_count"],
|
||||
"penalty_strokes": h["penalty_strokes"],
|
||||
"anyway_strokes": h["anyway_strokes"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/rounds/stats/summary", response_model=RoundStatsWindowSummary)
|
||||
async def get_rounds_stats_summary(
|
||||
window: StatsWindow = Query("all"),
|
||||
|
|
@ -2202,22 +2289,40 @@ async def get_rounds_stats_summary(
|
|||
by_round: dict[str, list] = {}
|
||||
stat_level_by_round: dict[str, str] = {}
|
||||
played_at_by_round: dict[str, date] = {}
|
||||
source_by_round: dict[str, str] = {}
|
||||
for r in rows:
|
||||
by_round.setdefault(r["round_id"], []).append(r)
|
||||
stat_level_by_round[r["round_id"]] = r["stat_level"]
|
||||
played_at_by_round[r["round_id"]] = r["played_at"]
|
||||
source_by_round[r["round_id"]] = "personal"
|
||||
|
||||
# Distinkte runder, nyest først -- rekkefølgen `rows` allerede har
|
||||
# (ORDER BY r.played_at DESC) bevares siden Python-dicts er innsettings-
|
||||
# ordnet, ingen ny sortering nødvendig.
|
||||
ordered_rounds = [(rid, played_at_by_round[rid]) for rid in by_round]
|
||||
# Org-turnering-runder (ADR: se CHANGELOG) blandes inn i de SAMME
|
||||
# dict-ene -- `_resolve_stats_window`/`_summarize_rounds`/
|
||||
# `_build_round_series` forblir uendret, de aksesserer felt via
|
||||
# `h["felt"]` og bryr seg ikke om raden kom fra `round_hole` eller
|
||||
# `tournament_round_hole`.
|
||||
for r in await _fetch_tournament_stats_rows(user.user_id):
|
||||
rid = r["round_id"]
|
||||
by_round.setdefault(rid, []).append(r)
|
||||
stat_level_by_round[rid] = r["stat_level"]
|
||||
played_at_by_round[rid] = r["played_at"]
|
||||
source_by_round[rid] = r["source"]
|
||||
|
||||
# Nyest først -- må sorteres eksplisitt nå (kunne før stole på at
|
||||
# SQL-en sin ORDER BY ga riktig innsettingsrekkefølge, men det
|
||||
# gjelder ikke lenger etter sammenslåingen over).
|
||||
ordered_rounds = sorted(
|
||||
((rid, played_at_by_round[rid]) for rid in by_round),
|
||||
key=lambda pair: pair[1],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)
|
||||
|
||||
return RoundStatsWindowSummary(
|
||||
window=window,
|
||||
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round),
|
||||
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round),
|
||||
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round, source_by_round),
|
||||
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round, source_by_round),
|
||||
round_series=_build_round_series(current_ids, by_round, stat_level_by_round, played_at_by_round),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ type ApiRoundSeriesPoint = {
|
|||
|
||||
type ApiStatsSummary = {
|
||||
rounds_completed: number
|
||||
rounds_completed_personal: number
|
||||
rounds_completed_tournament: number
|
||||
avg_score_to_par: number | null
|
||||
avg_putts_per_18: number | null
|
||||
putts_tracked_rounds: number
|
||||
|
|
@ -835,6 +837,9 @@ export function RoundsStatsSummary() {
|
|||
<>
|
||||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
||||
Aggregert over {s.rounds_completed} {s.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}
|
||||
{s.rounds_completed_tournament > 0 && (
|
||||
<> ({s.rounds_completed_personal} frittstående + {s.rounds_completed_tournament} turnering)</>
|
||||
)}
|
||||
{p.rounds_completed > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
172
tests/test_rounds_stats_tournament.py
Normal file
172
tests/test_rounds_stats_tournament.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""
|
||||
GET /rounds/stats/summary skal blande org-turnering-runder inn i de samme
|
||||
trend-/snitt-tallene som frittstående runder (scoring-paritet-gap punkt 3,
|
||||
se FEATURE_BACKLOG.md), med kilde-antall vist separat
|
||||
(`rounds_completed_personal`/`rounds_completed_tournament`) -- speiler
|
||||
`app/hole_history.py` sin `times_personal`/`times_tournament`-presedens.
|
||||
|
||||
Speiler `test_hole_history.py` sitt integrasjonsmønster (ekte router-
|
||||
funksjoner, ikke rå SQL for selve scoringen) for personal/tournament-
|
||||
scoring, men mot `get_rounds_stats_summary` i stedet for hull-historikk.
|
||||
"""
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.routers.individual_tournaments import (
|
||||
HoleUpdate as TournamentHoleUpdate,
|
||||
update_hole as tournament_update_hole,
|
||||
)
|
||||
from app.routers.rounds import HoleUpdate as RoundHoleUpdate, get_rounds_stats_summary, update_hole as round_update_hole
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 _make_completed_personal_round(user_id, *, score_per_hole, putts_per_hole=None, par=4,
|
||||
stat_level="strokes_and_putts"):
|
||||
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)
|
||||
for n in range(1, 19):
|
||||
await create_hole(conn, participant_id, hole_number=n, par=par, stroke_index=n)
|
||||
await conn.execute("UPDATE round_participant SET stat_level = $1 WHERE id = $2", stat_level, participant_id)
|
||||
for n in range(1, 19):
|
||||
await _score_personal_hole(
|
||||
round_id, participant_id, user_id, n, score_per_hole,
|
||||
putts=putts_per_hole,
|
||||
)
|
||||
async with app_db.plain_connection() as conn:
|
||||
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
|
||||
return round_id, participant_id
|
||||
|
||||
|
||||
async def _make_tournament_round(
|
||||
user_id, *, score_per_hole, putts_per_hole=None, par=4, holes_scored=18,
|
||||
stat_level="strokes_and_putts", set_scheduled_at=True, org_id=None,
|
||||
):
|
||||
if org_id is None:
|
||||
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)
|
||||
for n in range(1, 19):
|
||||
await create_org_hole(org_id, course_id, hole_number=n, par=par, 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)
|
||||
if set_scheduled_at:
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute("UPDATE tournament_round SET scheduled_at = now() WHERE id = $1", tournament_round_id)
|
||||
player_id = await create_player(org_id, user_id=user_id)
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute("UPDATE tournament_participant SET stat_level = $1 WHERE id = $2", stat_level, tp_id)
|
||||
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
|
||||
for n in range(1, holes_scored + 1):
|
||||
await _score_tournament_hole(
|
||||
tournament_id, tournament_round_id, rp_id, org_id, user_id, n, score_per_hole,
|
||||
putts=putts_per_hole,
|
||||
)
|
||||
return org_id, tournament_round_id, rp_id
|
||||
|
||||
|
||||
async def test_blends_personal_and_tournament_rounds(pool):
|
||||
user_id = await create_user()
|
||||
await _make_completed_personal_round(user_id, score_per_hole=5, putts_per_hole=2, par=4) # +18 til par
|
||||
await _make_tournament_round(user_id, score_per_hole=4, putts_per_hole=2, par=4) # +0 til par
|
||||
|
||||
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
|
||||
|
||||
assert out.current.rounds_completed == 2
|
||||
assert out.current.rounds_completed_personal == 1
|
||||
assert out.current.rounds_completed_tournament == 1
|
||||
assert out.current.avg_score_to_par == 9.0 # (18 + 0) / 2
|
||||
|
||||
|
||||
async def test_tournament_strokes_only_round_excluded_from_putts(pool):
|
||||
user_id = await create_user()
|
||||
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
|
||||
await _make_tournament_round(
|
||||
user_id, score_per_hole=4, putts_per_hole=None, par=4, stat_level="strokes_only",
|
||||
)
|
||||
|
||||
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
|
||||
|
||||
assert out.current.rounds_completed == 2
|
||||
assert out.current.rounds_completed_tournament == 1
|
||||
# Kun den frittstående runden sporet putter -- strokes_only-turnerings-
|
||||
# runden skal IKKE forurense putt-snittet (samme regel som gjelder for
|
||||
# en frittstående strokes_only-runde).
|
||||
assert out.current.putts_tracked_rounds == 1
|
||||
assert out.current.avg_putts_per_18 == 36.0
|
||||
|
||||
|
||||
async def test_incomplete_tournament_round_is_excluded(pool):
|
||||
user_id = await create_user()
|
||||
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
|
||||
await _make_tournament_round(user_id, score_per_hole=4, holes_scored=10) # kun 10 av 18 hull
|
||||
|
||||
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
|
||||
|
||||
assert out.current.rounds_completed == 1
|
||||
assert out.current.rounds_completed_personal == 1
|
||||
assert out.current.rounds_completed_tournament == 0
|
||||
|
||||
|
||||
async def test_tournament_round_without_scheduled_at_is_excluded(pool):
|
||||
user_id = await create_user()
|
||||
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
|
||||
await _make_tournament_round(user_id, score_per_hole=4, set_scheduled_at=False)
|
||||
|
||||
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
|
||||
|
||||
assert out.current.rounds_completed == 1
|
||||
assert out.current.rounds_completed_tournament == 0
|
||||
|
||||
|
||||
async def test_tournament_round_in_foreign_org_without_player_row_is_excluded(pool):
|
||||
"""`player_organizations_for_user` returnerer kun org-er brukeren har en
|
||||
`player`-rad i -- en fullført turneringsrunde i en org der brukeren
|
||||
IKKE er spiller skal aldri kunne telles med i brukerens egen statistikk."""
|
||||
user_id = await create_user()
|
||||
other_user_id = await create_user()
|
||||
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
|
||||
# Turneringsrunde scoret for en ANNEN bruker, i en org `user_id` ikke er medlem/spiller i.
|
||||
await _make_tournament_round(other_user_id, score_per_hole=4, putts_per_hole=2, par=4)
|
||||
|
||||
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
|
||||
|
||||
assert out.current.rounds_completed == 1
|
||||
assert out.current.rounds_completed_tournament == 0
|
||||
Loading…
Reference in a new issue