Order of Merit: lag-leaderboard + eclectic-total-bug fikset (ADR-074)
Fikser først en reell bug: en lenket eclectic-scoret turnering bidro med rå slagsum i stedet for eclectic_total (turneringens faktiske "drømmerunde"-resultat) i stableford/brutto/netto-OOM-er -- bugen fantes siden Eclectic-formatet ble bygget (ADR-068), OOM ble aldri revidert. Bygger deretter lag-OOM sin leaderboard (ADR-043 Beslutning C, skjema fantes fra migrasjon 055): ny CRUD for team/medlemmer, og leaderboard- grenen for kind='team' som summerer/velger-beste-N-av medlemmenes allerede beregnede individuelle OOM-resultater -- samme order_of_merit_aggregate-kall gjenbrukt på lagnivå med OOM-ens allerede lagrede aggregeringsvalg. Frontend: fjernet "ikke bygget ennå"- plassholderen, den eksisterende leaderboard-tabellen var allerede generisk nok. Ny lag-administrasjonsseksjon lagt til. Ingen migrasjon. Se ADR-074/CHANGELOG for full detalj og verifisering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3770509c46
commit
5d502f305a
5 changed files with 976 additions and 50 deletions
|
|
@ -7310,6 +7310,96 @@ Containeren startet rent.
|
|||
|
||||
---
|
||||
|
||||
## ADR-074: Order of Merit -- lag-leaderboard + eclectic-total-bug fikset ("Steg 0+1" av gjenstår-listen) (2026-08-15)
|
||||
|
||||
Foranledning: bruker ba om å gå videre med de to store gjenstående OOM-
|
||||
punktene fra ADR-043 (spiller-OOM, 2026-08-04): lag-OOM sin faktiske
|
||||
leaderboard-beregning (skjema fantes, `GET .../leaderboard` avviste
|
||||
eksplisitt for `kind='team'`), og eclectic-aggregering på tvers av
|
||||
lenkede turneringer. Utvidet til tre punkter da FEATURE_BACKLOG.md sin
|
||||
"gjenstår"-liste viste seg å ha et tredje, ikke opprinnelig nevnt punkt
|
||||
(offentlig/delt visning) -- bekreftet med bruker at alle tre tas i denne
|
||||
runden (se ADR-075/076 for de to andre).
|
||||
|
||||
**Bifunn tatt FØRST, uavhengig av resten:** `_contribution_for_entry`
|
||||
(brukt av BÅDE eksisterende sum/snitt-OOM-er og den nye eclectic-
|
||||
aggregeringen) leste alltid `entry.gross_total`/`net_total`/
|
||||
`stableford_total` for en lenket turnering -- ALDRI `entry.
|
||||
eclectic_total`, selv når den lenkede turneringens `scoring_method` var
|
||||
`eclectic_gross`/`net`/`stableford` (ADR-068, bygget 2026-08-14, EFTER
|
||||
OOM-en selv 2026-08-04 -- aldri revidert siden). Konsekvens: en klubb som
|
||||
lenket en eclectic-scoret turnering inn i en stableford/brutto/netto-OOM
|
||||
fikk stille FEIL tall -- spillerens rå slagsum på tvers av rundene (et
|
||||
tall som ikke engang avgjorde DEN turneringens egen vinner), i stedet
|
||||
for dens faktiske "drømmerunde"-resultat. Fikset: `_contribution_for_
|
||||
entry` tar nå også imot turneringens `scoring_method` (allerede
|
||||
tilgjengelig fra `_compute_individual_standings` sin returnerte
|
||||
metadata, kun forkastet før) og leser `eclectic_total` når relevant.
|
||||
|
||||
**Lag-OOM sin leaderboard** (ADR-043 Beslutning C, aldri bygget):
|
||||
- Ny CRUD (`order_of_merit_team`/`_team_member`, tabellene fantes
|
||||
allerede fra migrasjon 055 -- INGEN ny migrasjon i dette steget):
|
||||
`POST/GET/DELETE .../order-of-merits/{id}/teams`,
|
||||
`POST/DELETE .../teams/{id}/members`. Samme mønster som
|
||||
`order_of_merit_link`-CRUD-en (eksisterer-sjekk + `translate_db_
|
||||
errors()`). `player_id`-FK-en i skjemaet er IKKE org-scopet (migrasjon
|
||||
055-avgjørelse) -- egen eksplisitt eksistenssjekk lagt til i
|
||||
`add_order_of_merit_team_member` for å ikke stole blindt på klienten.
|
||||
- Leaderboard-grenen for `kind='team'` erstatter den tidligere eksplisitte
|
||||
avvisningen: for hvert lag, regn ut HVERT medlems allerede-etablerte
|
||||
individuelle OOM-sluttresultat (samme `_player_eligibility`+
|
||||
`_aggregate_value`-par som spillernivået, faktorisert ut og delt), slå
|
||||
deretter disse medlemsverdiene sammen med SAMME `order_of_merit_
|
||||
aggregate`-kall -- gjenbrukt EN GANG TIL på lagnivå, med OOM-ens
|
||||
ALLEREDE LAGREDE `aggregation_mode`/`count_best_n`. **Bevisst
|
||||
designvalg, ingen ny konfig**: ETT lagret aggregeringsvalg styrer
|
||||
BEGGE nivåer -- `count_best_n=1` betyr dermed BÅDE "hver spillers
|
||||
beste 1 av N turneringer" OG "lagets beste 1 av M medlemmer" samtidig
|
||||
(bekreftet i test: `test_team_leaderboard_best_n_applies_at_both_
|
||||
levels`, ikke en overraskelse men en direkte konsekvens av
|
||||
gjenbruket). `aggregation_mode='eclectic'` er allerede sperret for
|
||||
`kind='team'` ved opprettelse (eksisterende sjekk, uendret) -- lagnivået
|
||||
trenger derfor aldri håndtere eclectic.
|
||||
- `OrderOfMeritLeaderboardEntry` sitt `player_id`/`player_name`-par
|
||||
gjenbrukes UENDRET for lagnivået (bærer der lagets EGEN id/navn) --
|
||||
ingen ny modell, kalleren vet allerede OOM-ens `kind`.
|
||||
|
||||
**Frontend**: `order-of-merit-detail.tsx` sin `kind === "team"`-
|
||||
placeholder ("Lag-resultatliste er ikke bygget ennå") fjernet helt --
|
||||
`LeaderboardTable` var allerede generisk nok til å vise lag-resultater
|
||||
uendret (leser kun `player_id`/`player_name`/`value`/`position`, ingen
|
||||
spiller-spesifikk logikk å fjerne). Ny "Lag"-seksjon (kun for
|
||||
`kind='team'`) mellom Innstillinger og Resultatliste -- samme liste+
|
||||
skjema-mønster som Lenkede turneringer-seksjonen: opprett/slett lag,
|
||||
legg til/fjern medlem fra org-ens spillerpool (enkelt `<select>`, samme
|
||||
presedens som andre administrasjons-sammenhenger i appen -- IKKE i strid
|
||||
med "ingen dropdowns"-regelen, som gjelder scoring-registrering
|
||||
spesifikt, se ADR-071).
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (77/77 -- 5 nye tester i
|
||||
`test_order_of_merit.py`: eclectic-total-bug-regresjon, lag-leaderboard
|
||||
summerer medlemmers individuelle OOM-totaler korrekt (håndregnet: 162 +
|
||||
198 = 360), `count_best_n` sin to-nivås-anvendelse bevist eksplisitt,
|
||||
full lag-CRUD + `kind='player'`-guard). `tsc --noEmit` rent + 45/45
|
||||
vitest. Egen scratch-database + scratch `teecup_api`-container (port
|
||||
18008, live-montert kode) + lokal `next dev` (port 13008): opprettet en
|
||||
ekte lag-OOM i nettleseren, lenket to turneringer, opprettet ett lag,
|
||||
la til to medlemmer ETT OM GANGEN -- bekreftet leaderboardet oppdaterte
|
||||
seg LIVE til riktig verdi ved hver endring (162 → 360 ved andre medlem,
|
||||
tilbake til 162 ved fjerning), gull-fremhevet førsteplass-rad vist
|
||||
korrekt. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
|
||||
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||||
|
||||
**Ingen migrasjon** -- ren kode-endring, tabellene fantes fra migrasjon
|
||||
055.
|
||||
|
||||
**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)
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -11530,3 +11530,45 @@ Neste steg:
|
|||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||||
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
|
||||
Containeren startet rent.
|
||||
|
||||
90. **Order of Merit: lag-leaderboard + eclectic-total-bug fikset
|
||||
(ADR-074) — 2026-08-15.** Går videre med de gjenstående OOM-punktene
|
||||
fra ADR-043 (2026-08-04): lag-OOM sin leaderboard, eclectic-
|
||||
aggregering på tvers av turneringer, og (nytt oppdaget) offentlig
|
||||
visning. Denne runden: bug-fiks + lag-OOM. Se ADR-075/076 for de to
|
||||
andre.
|
||||
|
||||
Bifunn fikset FØRST: `_contribution_for_entry` leste alltid rå
|
||||
gross/net/stableford_total for en lenket turnering, ALDRI
|
||||
`eclectic_total` -- en klubb som lenket en eclectic-scoret turnering
|
||||
(ADR-068, bygget etter OOM selv) inn i en stableford/brutto/netto-OOM
|
||||
fikk stille feil tall (rå slagsum i stedet for turneringens faktiske
|
||||
"drømmerunde"-resultat). Fikset ved å gi funksjonen tilgang til
|
||||
scoring_method.
|
||||
|
||||
Lag-OOM: ny CRUD for `order_of_merit_team`/`_team_member` (tabellene
|
||||
fantes fra migrasjon 055, ingen ny migrasjon). Leaderboard-grenen for
|
||||
`kind='team'` regner ut hvert medlems individuelle OOM-sluttresultat,
|
||||
slår dem sammen med SAMME `order_of_merit_aggregate`-kall gjenbrukt
|
||||
på lagnivå -- bevisst: ett lagret aggregeringsvalg styrer begge
|
||||
nivåer (count_best_n=1 betyr "beste turnering per spiller" OG "beste
|
||||
medlem per lag" samtidig, bekreftet eksplisitt i test). Frontend:
|
||||
fjernet "ikke bygget ennå"-plassholderen -- den eksisterende
|
||||
leaderboard-tabellen var allerede generisk nok til å vise lag
|
||||
uendret. Ny "Lag"-administrasjonsseksjon lagt til.
|
||||
|
||||
**Verifisert:** full `./scripts/run_backend_tests.sh` (77/77 -- 5 nye
|
||||
tester, inkl. håndregnet lag-sum 162+198=360 og en eksplisitt test av
|
||||
to-nivås count_best_n-anvendelsen). `tsc --noEmit` rent + 45/45
|
||||
vitest. Scratch-database + scratch `teecup_api` (port 18008, live-
|
||||
montert kode) + lokal `next dev` (port 13008): opprettet ekte lag-OOM
|
||||
i nettleseren, lenket to turneringer, la til to medlemmer ett om
|
||||
gangen -- leaderboardet oppdaterte seg live til riktig verdi ved hver
|
||||
endring. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
|
||||
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||||
|
||||
**Ingen migrasjon.**
|
||||
|
||||
**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.
|
||||
|
|
|
|||
|
|
@ -325,6 +325,177 @@ async def unlink_tournament_from_order_of_merit(
|
|||
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lag-OOM (ADR-043 Beslutning C, bygget ADR-074): sesong-par satt opp
|
||||
# DIREKTE i OOM-en (IKKE strengmatching mot turnering-lag, ADR-011-lag er
|
||||
# strukturelt urelatert -- se migrasjon 055 sin kommentar). Kun gyldig for
|
||||
# `kind='team'`. Selve leaderboard-beregningen (summerer/velger-beste-N-av
|
||||
# medlemmenes allerede beregnede individuelle OOM-resultater) står lenger
|
||||
# ned, sammen med spiller-leaderboardet den gjenbruker.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderOfMeritTeamCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class OrderOfMeritTeamMemberOut(BaseModel):
|
||||
id: str
|
||||
player_id: str
|
||||
player_name: str
|
||||
|
||||
|
||||
class OrderOfMeritTeamOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
members: list[OrderOfMeritTeamMemberOut]
|
||||
|
||||
|
||||
class OrderOfMeritTeamMemberCreate(BaseModel):
|
||||
player_id: str
|
||||
|
||||
|
||||
async def _require_team_kind(conn, oom_id: str) -> None:
|
||||
kind = await conn.fetchval("SELECT kind FROM order_of_merit WHERE id = $1", oom_id)
|
||||
if kind is None:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
if kind != "team":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Lag kan kun settes opp for en lag-OOM.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
|
||||
response_model=list[OrderOfMeritTeamOut],
|
||||
)
|
||||
async def list_order_of_merit_teams(
|
||||
oom_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[OrderOfMeritTeamOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
team_rows = await conn.fetch(
|
||||
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1 ORDER BY created_at",
|
||||
oom_id,
|
||||
)
|
||||
member_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT m.id::text, m.order_of_merit_team_id::text AS team_id,
|
||||
m.player_id::text AS player_id, p.display_name AS player_name
|
||||
FROM order_of_merit_team_member m
|
||||
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
|
||||
JOIN player p ON p.id = m.player_id
|
||||
WHERE t.order_of_merit_id = $1
|
||||
ORDER BY p.display_name
|
||||
""",
|
||||
oom_id,
|
||||
)
|
||||
members_by_team: dict[str, list[OrderOfMeritTeamMemberOut]] = {}
|
||||
for m in member_rows:
|
||||
members_by_team.setdefault(m["team_id"], []).append(
|
||||
OrderOfMeritTeamMemberOut(id=m["id"], player_id=m["player_id"], player_name=m["player_name"])
|
||||
)
|
||||
return [
|
||||
OrderOfMeritTeamOut(id=t["id"], name=t["name"], members=members_by_team.get(t["id"], []))
|
||||
for t in team_rows
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
|
||||
response_model=OrderOfMeritTeamOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_order_of_merit_team(
|
||||
oom_id: str,
|
||||
body: OrderOfMeritTeamCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritTeamOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
await _require_team_kind(conn, oom_id)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO order_of_merit_team (organization_id, order_of_merit_id, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id::text, name
|
||||
""",
|
||||
organization_id, oom_id, body.name,
|
||||
)
|
||||
return OrderOfMeritTeamOut(id=row["id"], name=row["name"], members=[])
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_order_of_merit_team(
|
||||
oom_id: str,
|
||||
team_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members",
|
||||
response_model=OrderOfMeritTeamMemberOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def add_order_of_merit_team_member(
|
||||
oom_id: str,
|
||||
team_id: str,
|
||||
body: OrderOfMeritTeamMemberCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritTeamMemberOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
team = await conn.fetchval(
|
||||
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
|
||||
)
|
||||
if team is None:
|
||||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
# player.id er IKKE org-scopet i sin FK (migrasjon 055) -- egen
|
||||
# eksplisitt sjekk her, samme forsiktighet som andre steder i
|
||||
# appen som tar imot en player_id fra klienten.
|
||||
player = await conn.fetchrow("SELECT display_name FROM player WHERE id = $1", body.player_id)
|
||||
if player is None:
|
||||
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO order_of_merit_team_member (organization_id, order_of_merit_team_id, player_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id::text
|
||||
""",
|
||||
organization_id, team_id, body.player_id,
|
||||
)
|
||||
return OrderOfMeritTeamMemberOut(id=row["id"], player_id=body.player_id, player_name=player["display_name"])
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members/{member_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def remove_order_of_merit_team_member(
|
||||
oom_id: str,
|
||||
team_id: str,
|
||||
member_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
team = await conn.fetchval(
|
||||
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
|
||||
)
|
||||
if team is None:
|
||||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
result = await conn.execute(
|
||||
"DELETE FROM order_of_merit_team_member WHERE id = $1 AND order_of_merit_team_id = $2",
|
||||
member_id, team_id,
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
||||
# som Nassau/High-low-high/individual_leaderboard selv).
|
||||
|
|
@ -353,6 +524,34 @@ def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
|||
return 0.0
|
||||
|
||||
|
||||
def _player_eligibility(raw_values: list[float], oom, birth_year: int | None) -> tuple[bool, str | None]:
|
||||
"""Delt mellom spiller- og lag-leaderboardet (et lags medlem er
|
||||
eligibility-sjekket EKSAKT som en spiller ville vært alene)."""
|
||||
results_available = len(raw_values)
|
||||
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||
if birth_year is None:
|
||||
return False, "Ingen registrert fødselsdato."
|
||||
if oom["birth_year_from"] is not None and birth_year < oom["birth_year_from"]:
|
||||
return False, "Utenfor aldersgrensen."
|
||||
if oom["birth_year_to"] is not None and birth_year > oom["birth_year_to"]:
|
||||
return False, "Utenfor aldersgrensen."
|
||||
if oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
|
||||
return False, f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
|
||||
return True, None
|
||||
|
||||
|
||||
def _aggregate_value(raw_values: list[float], oom, lower_is_better: bool) -> float | None:
|
||||
"""Negerer FØR order_of_merit_aggregate (som alltid regner «størst er
|
||||
best») og negerer tilbake etterpå for brutto/netto -- delt av spiller-
|
||||
OG lag-nivået. Trygt å kalle EN GANG TIL på lag-nivå med allerede
|
||||
korrekt-signerte medlemsverdier: samme frem-og-tilbake-negering gir
|
||||
riktig resultat begge ganger (ADR-074 Beslutning: ett lagret
|
||||
aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig)."""
|
||||
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
|
||||
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
|
||||
return -aggregated if lower_is_better and aggregated is not None else aggregated
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
||||
response_model=list[OrderOfMeritLeaderboardEntry],
|
||||
|
|
@ -372,8 +571,6 @@ async def order_of_merit_leaderboard(
|
|||
)
|
||||
if oom is None:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
if oom["kind"] != "player":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.")
|
||||
if oom["aggregation_mode"] == "eclectic":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
|
||||
|
||||
|
|
@ -394,11 +591,13 @@ async def order_of_merit_leaderboard(
|
|||
names_by_player: dict[str, str] = {}
|
||||
|
||||
for link in links:
|
||||
_, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||
points_table = _decode_json(link["points_table"])
|
||||
money_payout_table = _decode_json(link["money_payout_table"])
|
||||
for e in entries:
|
||||
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table)
|
||||
contribution = _contribution_for_entry(
|
||||
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
|
||||
)
|
||||
if contribution is None:
|
||||
continue
|
||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||
|
|
@ -418,44 +617,94 @@ async def order_of_merit_leaderboard(
|
|||
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
||||
|
||||
results: list[OrderOfMeritLeaderboardEntry] = []
|
||||
for player_id, raw_values in values_by_player.items():
|
||||
results_available = len(raw_values)
|
||||
eligible = True
|
||||
ineligible_reason = None
|
||||
|
||||
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||
by = birth_years.get(player_id)
|
||||
if by is None:
|
||||
eligible = False
|
||||
ineligible_reason = "Ingen registrert fødselsdato."
|
||||
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
|
||||
eligible = False
|
||||
ineligible_reason = "Utenfor aldersgrensen."
|
||||
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
|
||||
eligible = False
|
||||
ineligible_reason = "Utenfor aldersgrensen."
|
||||
if oom["kind"] == "player":
|
||||
for player_id, raw_values in values_by_player.items():
|
||||
eligible, ineligible_reason = _player_eligibility(raw_values, oom, birth_years.get(player_id))
|
||||
value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||
results_available = len(raw_values)
|
||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||
|
||||
if eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
|
||||
eligible = False
|
||||
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
|
||||
|
||||
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
|
||||
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
|
||||
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
|
||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||
|
||||
results.append(
|
||||
OrderOfMeritLeaderboardEntry(
|
||||
player_id=player_id,
|
||||
player_name=names_by_player[player_id],
|
||||
results_counted=counted,
|
||||
results_available=results_available,
|
||||
value=value,
|
||||
position=None,
|
||||
eligible=eligible,
|
||||
ineligible_reason=ineligible_reason,
|
||||
results.append(
|
||||
OrderOfMeritLeaderboardEntry(
|
||||
player_id=player_id,
|
||||
player_name=names_by_player[player_id],
|
||||
results_counted=counted,
|
||||
results_available=results_available,
|
||||
value=value,
|
||||
position=None,
|
||||
eligible=eligible,
|
||||
ineligible_reason=ineligible_reason,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Lag-OOM (ADR-043 Beslutning C, ADR-074): summerer/velger-
|
||||
# beste-N-av medlemmenes allerede beregnede individuelle OOM-
|
||||
# resultater -- IKKE strengmatching mot turnering-lag.
|
||||
# `player_id`/`player_name` på OrderOfMeritLeaderboardEntry
|
||||
# bærer her lagets EGEN id/navn (samme uttrykk, uendret
|
||||
# skjema -- kalleren vet allerede OOM-ens `kind`).
|
||||
team_rows = await conn.fetch(
|
||||
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1", oom_id
|
||||
)
|
||||
member_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id
|
||||
FROM order_of_merit_team_member m
|
||||
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
|
||||
WHERE t.order_of_merit_id = $1
|
||||
""",
|
||||
oom_id,
|
||||
)
|
||||
member_ids_by_team: dict[str, list[str]] = {}
|
||||
for m in member_rows:
|
||||
member_ids_by_team.setdefault(m["team_id"], []).append(m["player_id"])
|
||||
|
||||
for team in team_rows:
|
||||
member_ids = member_ids_by_team.get(team["id"], [])
|
||||
member_values: list[float] = []
|
||||
for pid in member_ids:
|
||||
raw_values = values_by_player.get(pid)
|
||||
if not raw_values:
|
||||
continue
|
||||
member_eligible, _ = _player_eligibility(raw_values, oom, birth_years.get(pid))
|
||||
if not member_eligible:
|
||||
continue
|
||||
member_value = _aggregate_value(raw_values, oom, lower_is_better)
|
||||
if member_value is not None:
|
||||
member_values.append(member_value)
|
||||
|
||||
eligible_members = len(member_values)
|
||||
team_eligible = eligible_members > 0
|
||||
team_reason = None if team_eligible else "Ingen av lagets medlemmer har tellende resultater."
|
||||
if (
|
||||
team_eligible
|
||||
and oom["min_results_required"] is not None
|
||||
and eligible_members < oom["min_results_required"]
|
||||
):
|
||||
team_eligible = False
|
||||
team_reason = (
|
||||
f"Trenger minst {oom['min_results_required']} medlemmer med tellende resultater, "
|
||||
f"har {eligible_members}."
|
||||
)
|
||||
|
||||
value = _aggregate_value(member_values, oom, lower_is_better) if team_eligible else None
|
||||
counted = (
|
||||
min(oom["count_best_n"], eligible_members) if oom["count_best_n"] is not None else eligible_members
|
||||
)
|
||||
|
||||
results.append(
|
||||
OrderOfMeritLeaderboardEntry(
|
||||
player_id=team["id"],
|
||||
player_name=team["name"],
|
||||
results_counted=counted,
|
||||
results_available=len(member_ids),
|
||||
value=value,
|
||||
position=None,
|
||||
eligible=team_eligible,
|
||||
ineligible_reason=team_reason,
|
||||
)
|
||||
)
|
||||
|
||||
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
|
||||
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
||||
|
|
@ -483,7 +732,12 @@ def _decode_json(value):
|
|||
return json.loads(value) if value else None
|
||||
|
||||
|
||||
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None:
|
||||
_ECLECTIC_METHODS = ("eclectic_gross", "eclectic_net", "eclectic_stableford")
|
||||
|
||||
|
||||
def _contribution_for_entry(
|
||||
entry, result_type: str, points_table, money_payout_table, scoring_method: str | None = None
|
||||
) -> float | None:
|
||||
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
||||
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
||||
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
||||
|
|
@ -495,13 +749,23 @@ def _contribution_for_entry(entry, result_type: str, points_table, money_payout_
|
|||
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
||||
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
||||
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
||||
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt."""
|
||||
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.
|
||||
|
||||
Bug fikset 2026-08-15 (ADR-074): for en lenket turnering med
|
||||
scoring_method IN eclectic_gross/net/stableford, skal
|
||||
stableford/gross/net-resultattype-OOM-er lese `entry.eclectic_total`
|
||||
(turneringens FAKTISKE "drømmerunde"-resultat, det som avgjorde DEN
|
||||
turneringens egen vinner) -- IKKE `entry.stableford_total`/
|
||||
`gross_total`/`net_total` (rå sum på tvers av rundene, et tall som
|
||||
aldri var det som ble kåret som resultat i den turneringen)."""
|
||||
if entry.rounds_played == 0:
|
||||
return None
|
||||
if result_type == "points":
|
||||
return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
|
||||
if result_type == "money":
|
||||
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
|
||||
if scoring_method in _ECLECTIC_METHODS and result_type in ("stableford", "gross", "net"):
|
||||
return float(entry.eclectic_total) if entry.eclectic_total is not None else 0.0
|
||||
if result_type == "stableford":
|
||||
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
||||
if result_type == "gross":
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ type ApiLeaderboardEntry = {
|
|||
|
||||
type ApiTournament = { id: string; name: string; format_type: string }
|
||||
|
||||
// Lag-OOM (ADR-043 Beslutning C, ADR-074) -- sesong-par satt opp DIREKTE i
|
||||
// OOM-en, ikke turnering-lag. Kun relevant når oom.kind === "team".
|
||||
type ApiOomTeamMember = { id: string; player_id: string; player_name: string }
|
||||
type ApiOomTeam = { id: string; name: string; members: ApiOomTeamMember[] }
|
||||
type ApiPlayer = { id: string; display_name: string }
|
||||
|
||||
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||
points: "Poeng etter plassering",
|
||||
|
|
@ -83,6 +89,8 @@ export function OrderOfMeritDetail({
|
|||
const [links, setLinks] = useState<ApiLink[] | null>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
|
||||
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
|
||||
const [teams, setTeams] = useState<ApiOomTeam[] | null>(null)
|
||||
const [players, setPlayers] = useState<ApiPlayer[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
|
||||
|
|
@ -114,6 +122,16 @@ export function OrderOfMeritDetail({
|
|||
if (res.ok) setAllTournaments(await res.json())
|
||||
}
|
||||
|
||||
async function loadTeams() {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { credentials: "include" })
|
||||
if (res.ok) setTeams(await res.json())
|
||||
}
|
||||
|
||||
async function loadPlayers() {
|
||||
const res = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
|
||||
if (res.ok) setPlayers(await res.json())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadOom()
|
||||
void loadLinks()
|
||||
|
|
@ -122,7 +140,12 @@ export function OrderOfMeritDetail({
|
|||
}, [organizationId, oomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (oom?.kind === "player") void loadLeaderboard()
|
||||
if (!oom) return
|
||||
void loadLeaderboard()
|
||||
if (oom.kind === "team") {
|
||||
void loadTeams()
|
||||
void loadPlayers()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [oom?.kind, links])
|
||||
|
||||
|
|
@ -160,6 +183,65 @@ export function OrderOfMeritDetail({
|
|||
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
|
||||
}
|
||||
|
||||
async function createTeam(name: string) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError("Klarte ikke å opprette laget.")
|
||||
return
|
||||
}
|
||||
await loadTeams()
|
||||
}
|
||||
|
||||
async function deleteTeam(teamId: string) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.status !== 204) {
|
||||
setError("Klarte ikke å slette laget.")
|
||||
return
|
||||
}
|
||||
setTeams((prev) => (prev ?? []).filter((t) => t.id !== teamId))
|
||||
await loadLeaderboard()
|
||||
}
|
||||
|
||||
async function addMember(teamId: string, playerId: string) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ player_id: playerId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError("Klarte ikke å legge til spilleren.")
|
||||
return
|
||||
}
|
||||
await loadTeams()
|
||||
await loadLeaderboard()
|
||||
}
|
||||
|
||||
async function removeMember(teamId: string, memberId: string) {
|
||||
setError(null)
|
||||
const res = await fetch(
|
||||
`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members/${memberId}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (res.status !== 204) {
|
||||
setError("Klarte ikke å fjerne spilleren.")
|
||||
return
|
||||
}
|
||||
await loadTeams()
|
||||
await loadLeaderboard()
|
||||
}
|
||||
|
||||
async function saveSettings(patch: Record<string, unknown>) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||
|
|
@ -257,7 +339,18 @@ export function OrderOfMeritDetail({
|
|||
|
||||
<SettingsSection oom={oom} onSave={saveSettings} />
|
||||
|
||||
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} />
|
||||
{oom.kind === "team" && (
|
||||
<TeamsSection
|
||||
teams={teams}
|
||||
players={players}
|
||||
onCreateTeam={createTeam}
|
||||
onDeleteTeam={deleteTeam}
|
||||
onAddMember={addMember}
|
||||
onRemoveMember={removeMember}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -651,11 +744,9 @@ function SettingsSegment({ selected, onClick, children }: { selected: boolean; o
|
|||
// --- D. Resultatliste ----------------------------------------------------------
|
||||
|
||||
function LeaderboardSection({
|
||||
kind,
|
||||
resultType,
|
||||
entries,
|
||||
}: {
|
||||
kind: OomKind
|
||||
resultType: OomResultType
|
||||
entries: ApiLeaderboardEntry[] | null
|
||||
}) {
|
||||
|
|
@ -668,12 +759,7 @@ function LeaderboardSection({
|
|||
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
|
||||
</div>
|
||||
|
||||
{kind === "team" ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
|
||||
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
|
||||
</div>
|
||||
) : entries === null ? (
|
||||
{entries === null ? (
|
||||
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||
) : (
|
||||
<LeaderboardTable entries={entries} resultType={resultType} />
|
||||
|
|
@ -682,6 +768,193 @@ function LeaderboardSection({
|
|||
)
|
||||
}
|
||||
|
||||
// --- C.5 Lag (kun kind === "team", ADR-043 Beslutning C, ADR-074) ----------
|
||||
// Sesong-par satt opp DIREKTE i OOM-en -- IKKE turnering-lag. Samme
|
||||
// liste+skjema-mønster som "Lenkede turneringer" over.
|
||||
|
||||
function TeamsSection({
|
||||
teams,
|
||||
players,
|
||||
onCreateTeam,
|
||||
onDeleteTeam,
|
||||
onAddMember,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
teams: ApiOomTeam[] | null
|
||||
players: ApiPlayer[]
|
||||
onCreateTeam: (name: string) => Promise<void>
|
||||
onDeleteTeam: (teamId: string) => Promise<void>
|
||||
onAddMember: (teamId: string, playerId: string) => Promise<void>
|
||||
onRemoveMember: (teamId: string, memberId: string) => Promise<void>
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreateTeam(name.trim())
|
||||
setName("")
|
||||
setAdding(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Lag</h2>
|
||||
</div>
|
||||
|
||||
{teams === null ? (
|
||||
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||
) : teams.length === 0 ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Ingen lag opprettet ennå -- lag er sesong-par satt opp direkte her, ikke koblet til noe turnering-lag.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{teams.map((t) => (
|
||||
<TeamCard
|
||||
key={t.id}
|
||||
team={t}
|
||||
players={players}
|
||||
onDelete={() => onDeleteTeam(t.id)}
|
||||
onAddMember={(playerId) => onAddMember(t.id, playerId)}
|
||||
onRemoveMember={(memberId) => onRemoveMember(t.id, memberId)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!adding ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdding(true)}
|
||||
className="h-11 self-start rounded-xl text-sm font-bold"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Nytt lag
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2 rounded-2xl border border-border bg-background p-3">
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Lagnavn"
|
||||
className="h-10 flex-1 rounded-lg text-sm"
|
||||
/>
|
||||
<Button type="submit" disabled={!name.trim() || submitting} className="h-10 rounded-lg text-sm font-bold">
|
||||
{submitting ? "Oppretter…" : "Opprett"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAdding(false)
|
||||
setName("")
|
||||
}}
|
||||
className="h-10 rounded-lg text-sm font-semibold text-muted-foreground"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TeamCard({
|
||||
team,
|
||||
players,
|
||||
onDelete,
|
||||
onAddMember,
|
||||
onRemoveMember,
|
||||
}: {
|
||||
team: ApiOomTeam
|
||||
players: ApiPlayer[]
|
||||
onDelete: () => Promise<void>
|
||||
onAddMember: (playerId: string) => Promise<void>
|
||||
onRemoveMember: (memberId: string) => Promise<void>
|
||||
}) {
|
||||
const memberPlayerIds = new Set(team.members.map((m) => m.player_id))
|
||||
const availablePlayers = players.filter((p) => !memberPlayerIds.has(p.id))
|
||||
const [selectedPlayerId, setSelectedPlayerId] = useState("")
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-bold text-foreground">{team.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-9 shrink-0 rounded-lg px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
Slett lag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{team.members.length > 0 && (
|
||||
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border">
|
||||
{team.members.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<span className="text-sm text-foreground">{m.player_name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveMember(m.id)}
|
||||
className="h-8 shrink-0 rounded-lg px-2 text-xs font-semibold text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Fjern
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{availablePlayers.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedPlayerId}
|
||||
onChange={(e) => setSelectedPlayerId(e.target.value)}
|
||||
className="h-10 flex-1 rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||
>
|
||||
<option value="">Velg spiller…</option>
|
||||
{availablePlayers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!selectedPlayerId}
|
||||
onClick={() => {
|
||||
void onAddMember(selectedPlayerId)
|
||||
setSelectedPlayerId("")
|
||||
}}
|
||||
className="h-10 shrink-0 rounded-lg text-sm font-semibold"
|
||||
>
|
||||
Legg til
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
|
||||
const eligible = entries.filter((e) => e.eligible)
|
||||
const ineligible = entries.filter((e) => !e.eligible)
|
||||
|
|
|
|||
257
tests/test_order_of_merit.py
Normal file
257
tests/test_order_of_merit.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"""
|
||||
Order of Merit (ADR-043) -- Steg 0/1/2-tester (ADR-074/075). Kaller de
|
||||
faktiske router-funksjonene direkte (samme mønster som
|
||||
test_tournament_hole_stats.py/test_hole_history.py).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.routers.individual_tournaments import (
|
||||
HoleUpdate as TournamentHoleUpdate,
|
||||
update_hole as tournament_update_hole,
|
||||
)
|
||||
from app.routers.order_of_merit import (
|
||||
OrderOfMeritCreate,
|
||||
OrderOfMeritLinkCreate,
|
||||
OrderOfMeritTeamCreate,
|
||||
OrderOfMeritTeamMemberCreate,
|
||||
add_order_of_merit_team_member,
|
||||
create_order_of_merit,
|
||||
create_order_of_merit_team,
|
||||
delete_order_of_merit_team,
|
||||
link_tournament_to_order_of_merit,
|
||||
list_order_of_merit_teams,
|
||||
order_of_merit_leaderboard,
|
||||
remove_order_of_merit_team_member,
|
||||
)
|
||||
|
||||
from tests.conftest import (
|
||||
add_membership,
|
||||
create_org,
|
||||
create_org_hole,
|
||||
create_course,
|
||||
create_player,
|
||||
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_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score):
|
||||
user = CurrentUser(user_id=user_id)
|
||||
for n in range(1, 19):
|
||||
await tournament_update_hole(
|
||||
tournament_id, round_id, rp_id, n,
|
||||
TournamentHoleUpdate(gross_strokes=score, expected_version=None),
|
||||
organization_id=org_id, user=user,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_eclectic_tournament(org_id, user_id, method="eclectic_stableford"):
|
||||
tournament_id = await create_tournament(org_id, name="Eclectic OOM-turnering")
|
||||
course_id = await create_course(org_id, name="OOM Links")
|
||||
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)
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
|
||||
tournament_id, method,
|
||||
)
|
||||
player_id = await create_player(org_id, user_id=user_id, display_name="OOM Spiller")
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
|
||||
round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
|
||||
rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id, course_handicap=0)
|
||||
rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id, course_handicap=0)
|
||||
|
||||
# Runde 1: par (4) på alle 18 hull -> 2 poeng/hull = 36 Stableford-poeng.
|
||||
await _score_all_holes(tournament_id, round1_id, rp1_id, org_id, user_id, score=4)
|
||||
# Runde 2: bogey (5) på alle 18 hull -> 1 poeng/hull = 18 Stableford-poeng.
|
||||
await _score_all_holes(tournament_id, round2_id, rp2_id, org_id, user_id, score=5)
|
||||
# Rå SUM over begge runder = 36 + 18 = 54. Eclectic (beste PER HULL,
|
||||
# runde 1 vinner hver gang) = 18 hull * 2 poeng = 36 -- tydelig
|
||||
# forskjellig fra rå-summen, beviser at riktig tall faktisk brukes.
|
||||
return tournament_id, player_id
|
||||
|
||||
|
||||
async def test_oom_stableford_uses_eclectic_total_not_raw_sum(pool):
|
||||
"""ADR-074: en lenket eclectic_stableford-turnering skal bidra med
|
||||
entry.eclectic_total (36), IKKE rå stableford_total på tvers av
|
||||
begge runder (54)."""
|
||||
org_id = await create_org()
|
||||
owner_id = await create_user()
|
||||
await add_membership(org_id, owner_id, role="owner")
|
||||
tournament_id, player_id = await _setup_eclectic_tournament(org_id, owner_id)
|
||||
|
||||
oom = await create_order_of_merit(
|
||||
OrderOfMeritCreate(name="Sesong-OOM", kind="player", result_type="stableford", aggregation_mode="sum"),
|
||||
organization_id=org_id,
|
||||
)
|
||||
await link_tournament_to_order_of_merit(
|
||||
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_id), organization_id=org_id
|
||||
)
|
||||
|
||||
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
|
||||
assert len(leaderboard) == 1
|
||||
assert leaderboard[0].value == 36.0
|
||||
assert leaderboard[0].value != 54.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steg 1 (ADR-074): lag-OOM CRUD + leaderboard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _setup_stroke_gross_tournament(org_id, name, participants: list[tuple[str, int]]):
|
||||
"""participants: liste av (player_id, gross-score-per-hull) -- ETT
|
||||
18-hulls slagspill-runde, samme score på alle 18 hull for enkelhets
|
||||
skyld."""
|
||||
tournament_id = await create_tournament(org_id, name=name)
|
||||
course_id = await create_course(org_id, name=f"{name} bane")
|
||||
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)
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
await conn.execute(
|
||||
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
|
||||
tournament_id,
|
||||
)
|
||||
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||
for player_id, score in participants:
|
||||
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id, course_handicap=0)
|
||||
user_id = None
|
||||
async with app_db.org_connection(org_id) as conn:
|
||||
user_id = await conn.fetchval("SELECT user_id::text FROM player WHERE id = $1", player_id)
|
||||
await _score_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score=score)
|
||||
return tournament_id
|
||||
|
||||
|
||||
async def test_team_leaderboard_sums_member_individual_totals(pool):
|
||||
"""To turneringer lenket til en gross/sum lag-OOM. Spiller 1: 72+90=162.
|
||||
Spiller 2: 90+108=198. Ett lag med begge -> forventet lagsum 360."""
|
||||
org_id = await create_org()
|
||||
owner_id = await create_user()
|
||||
await add_membership(org_id, owner_id, role="owner")
|
||||
user1_id = await create_user(display_name="Spiller Én")
|
||||
user2_id = await create_user(display_name="Spiller To")
|
||||
player1_id = await create_player(org_id, user_id=user1_id, display_name="Spiller Én")
|
||||
player2_id = await create_player(org_id, user_id=user2_id, display_name="Spiller To")
|
||||
|
||||
tournament_a = await _setup_stroke_gross_tournament(
|
||||
org_id, "Turnering A", [(player1_id, 4), (player2_id, 5)]
|
||||
)
|
||||
tournament_b = await _setup_stroke_gross_tournament(
|
||||
org_id, "Turnering B", [(player1_id, 5), (player2_id, 6)]
|
||||
)
|
||||
|
||||
oom = await create_order_of_merit(
|
||||
OrderOfMeritCreate(name="Lag-OOM", kind="team", result_type="gross", aggregation_mode="sum"),
|
||||
organization_id=org_id,
|
||||
)
|
||||
await link_tournament_to_order_of_merit(
|
||||
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id
|
||||
)
|
||||
await link_tournament_to_order_of_merit(
|
||||
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id
|
||||
)
|
||||
|
||||
team = await create_order_of_merit_team(
|
||||
oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id
|
||||
)
|
||||
await add_order_of_merit_team_member(
|
||||
oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id
|
||||
)
|
||||
await add_order_of_merit_team_member(
|
||||
oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id
|
||||
)
|
||||
|
||||
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
|
||||
assert len(leaderboard) == 1
|
||||
assert leaderboard[0].player_id == team.id
|
||||
assert leaderboard[0].player_name == "Laget"
|
||||
assert leaderboard[0].value == 360.0
|
||||
assert leaderboard[0].results_available == 2
|
||||
assert leaderboard[0].results_counted == 2
|
||||
assert leaderboard[0].eligible is True
|
||||
|
||||
|
||||
async def test_team_leaderboard_best_n_applies_at_both_levels(pool):
|
||||
"""Samme oppsett som over, men count_best_n=1. ADR-074: ETT lagret
|
||||
aggregeringsvalg styrer BEGGE nivåer -- count_best_n=1 betyr derfor
|
||||
"hver spillers beste 1 av 2 turneringer" OG "lagets beste 1 av 2
|
||||
medlemmer", ikke bare det ene. Spiller 1 sin beste (laveste) turnering
|
||||
er 72, spiller 2 sin er 90 -- laget teller da KUN 72 (sitt beste
|
||||
medlem, som allerede er redusert til sitt eget beste resultat)."""
|
||||
org_id = await create_org()
|
||||
owner_id = await create_user()
|
||||
await add_membership(org_id, owner_id, role="owner")
|
||||
user1_id = await create_user()
|
||||
user2_id = await create_user()
|
||||
player1_id = await create_player(org_id, user_id=user1_id)
|
||||
player2_id = await create_player(org_id, user_id=user2_id)
|
||||
|
||||
tournament_a = await _setup_stroke_gross_tournament(org_id, "A", [(player1_id, 4), (player2_id, 5)])
|
||||
tournament_b = await _setup_stroke_gross_tournament(org_id, "B", [(player1_id, 5), (player2_id, 6)])
|
||||
|
||||
oom = await create_order_of_merit(
|
||||
OrderOfMeritCreate(
|
||||
name="Lag-OOM beste-1", kind="team", result_type="gross", aggregation_mode="sum", count_best_n=1
|
||||
),
|
||||
organization_id=org_id,
|
||||
)
|
||||
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
|
||||
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id)
|
||||
|
||||
team = await create_order_of_merit_team(oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id)
|
||||
await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id)
|
||||
await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id)
|
||||
|
||||
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
|
||||
assert leaderboard[0].value == 72.0
|
||||
assert leaderboard[0].results_counted == 1
|
||||
|
||||
|
||||
async def test_team_crud_and_kind_guard(pool):
|
||||
org_id = await create_org()
|
||||
owner_id = await create_user()
|
||||
await add_membership(org_id, owner_id, role="owner")
|
||||
player_id = await create_player(org_id)
|
||||
|
||||
player_oom = await create_order_of_merit(
|
||||
OrderOfMeritCreate(name="Spiller-OOM", kind="player", result_type="gross"), organization_id=org_id
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_order_of_merit_team(
|
||||
player_oom.id, OrderOfMeritTeamCreate(name="Skal feile"), organization_id=org_id
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
team_oom = await create_order_of_merit(
|
||||
OrderOfMeritCreate(name="Lag-OOM", kind="team", result_type="gross"), organization_id=org_id
|
||||
)
|
||||
team = await create_order_of_merit_team(team_oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id)
|
||||
member = await add_order_of_merit_team_member(
|
||||
team_oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player_id), organization_id=org_id
|
||||
)
|
||||
|
||||
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
||||
assert len(teams) == 1
|
||||
assert len(teams[0].members) == 1
|
||||
assert teams[0].members[0].player_id == player_id
|
||||
|
||||
await remove_order_of_merit_team_member(team_oom.id, team.id, member.id, organization_id=org_id)
|
||||
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
||||
assert len(teams[0].members) == 0
|
||||
|
||||
await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id)
|
||||
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
|
||||
assert len(teams) == 0
|
||||
Loading…
Reference in a new issue