Backend og frontend er bygget og scratch-verifisert (15 + 15 automatiserte sjekker), ADR-024 skrevet, .md-filer oppdatert. Ingen migrasjon denne runden (ren applogikk).

Klar for utrulling — samme mønster som sist:

docker compose up -d --build teecup_api teecup_frontend
Verifisere /health + /dashboard fortsatt 200, teeoff.no upåvirket
This commit is contained in:
Erol Haagenrud 2026-07-19 21:25:31 +02:00
parent c3cca377ed
commit ab23674521
6 changed files with 429 additions and 12 deletions

View file

@ -967,6 +967,62 @@ scratch-verifisering og utrulling.
---
## ADR-024: Walkover/konsesjon
Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.
**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
`user_is_team_captain`.
**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
varslingsmekanisme bygget.
**Beslutning C — Både match- og turnering-nivå, i samme runde.**
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
match. `POST /orgs/{id}/tournaments/{id}/concede`
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
dekker det allerede (rapporter bare hvem som vant hullet).
**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
spilt før konsesjonen.
**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
egne strenger (f.eks. "9&7 (A)").
**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (ren applogikk, ingen
skjemaendring). Se CLAUDE.md-status for scratch-verifisering og utrulling.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

View file

@ -352,19 +352,22 @@
Turnering har et `status`-felt (draft/active/completed/archived) men
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
ferdig via API-et i dag (eget, senere punkt).
- **Manglende WO/konsesjon:** hvis en side aldri stiller nok spillere
(`match_participant`-antallet når aldri det økten krever), beregnes
handicap ALDRI (se `app/handicap.py`), og matchen kan derfor ALDRI få et
hull-resultat — den blir hengende uavgjort for alltid. Det finnes ingen
«gi bort hullet/matchen/turneringen»-mekanisme (walkover/konsesjon) i det
hele tatt ennå — verken datamodell eller endepunkt.
- **Walkover/konsesjon — ✅ BYGGET 2026-07-19 (ADR-024).** Løste det
opprinnelige hullet: en side som aldri stiller nok spillere kan nå
avsluttes ved at den TAPENDE siden (kaptein, eller org-admin) erklærer
walkover — ensidig, ingen bekreftelse fra motparten. `POST /orgs/{id}/
matches/{id}/concede` (én match) og `POST /orgs/{id}/tournaments/{id}/
concede` (gi opp ALLE ikke-avgjorte matcher laget har, i én operasjon —
v1 er låst til nøyaktig to lag, så det finnes bare én motstander). Kan
erklæres uansett hvor mange hull som allerede er registrert (poengmessig
teller kun seier/tap/delt, ikke marginen) — allerede registrerte hull
forblir urørt i scorekortet. Se ARCHITECTURE_DECISIONS.md ADR-024.
Frontend: `session-scorecard.tsx` (gi opp én match) og
`tournament-detail.tsx` (gi opp resten av turneringen, per lag).
- **Avgjort 2026-07-16:**
- **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk
(ikke en handling noen utfører), så «hvem får låse» ble aldri et
spørsmål som trengte avklaring.
- **Walkover/konsesjon kan nå tas fatt på** — brukerroller (kaptein/
deltaker-avgrensning) er avgjort og bygget (ADR-023), grunnlaget den
tidligere ventet på er ikke lenger løst.
- **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok

View file

@ -30,6 +30,13 @@ status med en mindre komplett.
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til
akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/
`leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når
matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske
hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE
ikke-avgjorte matcher laget har, i én operasjon).
"""
import json
@ -43,7 +50,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..team_authz import user_is_match_participant
from ..team_authz import user_is_match_participant, user_is_team_captain
router = APIRouter()
@ -210,6 +217,97 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
)
class ConcedeOut(BaseModel):
match_id: str
status_text: str
points_side_a: float
points_side_b: float
leading_side: str
async def apply_concession(conn, match_id: str, conceding_side: str, points_per_match: float) -> ConcedeOut:
"""Setter matchen som avgjort ved walkover -- full poengsum til motstanderen,
uansett hvor mange hull som ev. allerede er registrert (marginen betyr
ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller selv
ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er
avgjort FØR denne kalles -- se de to kallstedene (matches.py sin
concede_match, tournaments.py sin concede_tournament)."""
winner = "b" if conceding_side == "a" else "a"
points_a = 0.0 if conceding_side == "a" else points_per_match
points_b = points_per_match if conceding_side == "a" else 0.0
status_text = f"Walkover ({winner.upper()})"
await conn.execute(
"""
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4
WHERE id = $5
""",
status_text,
points_a,
points_b,
winner,
match_id,
)
return ConcedeOut(
match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner
)
class ConcedeRequest(BaseModel):
conceding_team_id: str
@router.post(
"/orgs/{organization_id}/matches/{match_id}/concede",
response_model=ConcedeOut,
status_code=201,
)
async def concede_match(
match_id: str,
body: ConcedeRequest,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ConcedeOut:
"""Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget
som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier
motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen
bekreftelse fra motparten kreves -- samme tillitsnivå som all annen
scoring. Kan erklæres uansett hvor mange hull som allerede er
registrert."""
async with org_connection(organization_id) as conn, translate_db_errors():
match = await conn.fetchrow(
"""
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
m.points_side_a::float AS points_side_a,
s.points_per_match::float AS points_per_match
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if match["points_side_a"] is not None:
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
if body.conceding_team_id == match["team_a_id"]:
conceding_side = "a"
elif body.conceding_team_id == match["team_b_id"]:
conceding_side = "b"
else:
raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.")
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
raise app_error(
403,
"NOT_TEAM_CAPTAIN",
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.",
)
result = await apply_concession(conn, match_id, conceding_side, match["points_per_match"])
return result
class HoleScoreCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
match_participant_id: str | None = None

View file

@ -15,12 +15,13 @@ import asyncpg
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .scoring import recompute_and_cache_match_state
from ..team_authz import user_is_team_captain
from .scoring import apply_concession, recompute_and_cache_match_state
from .. import storage
router = APIRouter()
@ -268,6 +269,75 @@ async def create_team(
return Team(**dict(row))
# ---------------------------------------------------------------------------
# Turnering-nivå walkover/konsesjon (ADR-024)
#
# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander
# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen"
# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon,
# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match.
# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir
# seg selv, eller org-eier/admin.
# ---------------------------------------------------------------------------
class ConcedeTournamentRequest(BaseModel):
conceding_team_id: str
class ConcedeTournamentOut(BaseModel):
matches_conceded: int
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/concede",
response_model=ConcedeTournamentOut,
status_code=201,
)
async def concede_tournament(
tournament_id: str,
body: ConcedeTournamentRequest,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ConcedeTournamentOut:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1",
body.conceding_team_id,
)
if team is None or team["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.")
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
raise app_error(
403,
"NOT_TEAM_CAPTAIN",
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.",
)
# FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme
# race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state.
matches = await conn.fetch(
"""
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.points_per_match::float AS points_per_match
FROM match m
JOIN session s ON s.id = m.session_id
WHERE s.tournament_id = $1
AND m.points_side_a IS NULL
AND (m.team_a_id = $2 OR m.team_b_id = $2)
FOR UPDATE OF m
""",
tournament_id,
body.conceding_team_id,
)
for m in matches:
side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
await apply_concession(conn, m["id"], side, m["points_per_match"])
return ConcedeTournamentOut(matches_conceded=len(matches))
# ---------------------------------------------------------------------------
# Roster (spiller-pool per lag, ADR-007)
# ---------------------------------------------------------------------------

View file

@ -115,6 +115,8 @@ export function SessionScorecard({
const [error, setError] = useState<string | null>(null)
const [currentHole, setCurrentHole] = useState<number | null>(null)
const [showSummary, setShowSummary] = useState(false)
const [showConcede, setShowConcede] = useState(false)
const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null)
async function refetchScorecard() {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
@ -211,6 +213,31 @@ export function SessionScorecard({
}
}
// Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller
// org-admin) får lov -- backend håndhever dette, UI-et viser bare begge
// knappene og lar en 403 melde seg som en vanlig feiltekst, samme mønster
// som resten av appen (ingen klientside-forhåndsfiltrering på kapteinstatus).
async function concede(teamId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/concede`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ conceding_team_id: teamId }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å registrere walkover.")
}
setConcedeTeamIndex(null)
setShowConcede(false)
await refetchScorecard()
} catch (e) {
setError(e instanceof Error ? e.message : "Klarte ikke å registrere walkover.")
}
}
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
setError(null)
try {
@ -444,6 +471,73 @@ export function SessionScorecard({
)}
</div>
<div className="mb-6 overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => {
setShowConcede((v) => !v)
setConcedeTeamIndex(null)
}}
aria-expanded={showConcede}
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
>
<span className="text-sm font-semibold text-foreground">Gi opp matchen (walkover)</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
</button>
{showConcede && (
<div className="flex flex-col gap-3 border-t border-border p-4">
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
Kun kapteinen for laget som gir seg (eller en organisasjonsadministrator) kan
gjøre dette. Motstanderen får full poengsum for matchen, uansett hvor mange hull
som allerede er registrert.
</p>
{teams.map((team, i) =>
concedeTeamIndex === i ? (
<div
key={team.id}
className="flex flex-col gap-3 rounded-xl bg-destructive/5 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<span className="text-sm text-foreground text-pretty">
Er du sikker at <span className="font-semibold">{team.name}</span> gir seg?
</span>
<div className="flex shrink-0 gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="rounded-xl"
onClick={() => concede(team.id)}
>
Gi opp
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="rounded-xl"
onClick={() => setConcedeTeamIndex(null)}
>
Avbryt
</Button>
</div>
</div>
) : (
<Button
key={team.id}
type="button"
variant="outline"
className="h-11 justify-start rounded-xl text-sm font-semibold"
onClick={() => setConcedeTeamIndex(i as 0 | 1)}
>
<span aria-hidden="true" className="mr-2 size-3 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
{team.name} gir seg
</Button>
),
)}
</div>
)}
</div>
{session.scoring_mode === "stroke" ? (
<section aria-label={`Hull ${currentHole}`}>
<div className="mb-4 flex items-baseline gap-2">

View file

@ -7,6 +7,7 @@ import {
ArrowLeft,
Check,
Copy,
Flag,
KeyRound,
MoreVertical,
Plus,
@ -228,6 +229,33 @@ export function TournamentDetail({
}
}
// Walkover/konsesjon på turnering-nivå (ADR-024): gir opp ALLE
// ikke-avgjorte matcher laget har i turneringen, i én operasjon. Kun
// kaptein for laget som gir seg (eller org-admin) får lov -- backend
// håndhever dette, en 403 vises bare som vanlig feiltekst.
async function concedeTournament(teamId: string): Promise<string | null> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/concede`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ conceding_team_id: teamId }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const message: string = body?.detail?.message ?? "Klarte ikke å gi opp turneringen."
setError(message)
return message
}
return null
} catch {
const message = "Klarte ikke å gi opp turneringen."
setError(message)
return message
}
}
// Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen?
function findPlayerTeam(playerId: string): Team | null {
for (const t of teams) {
@ -319,6 +347,7 @@ export function TournamentDetail({
onRemovePlayer={removePlayer}
onAddExisting={addExistingPlayer}
onAddNew={addNewPlayer}
onConcedeTournament={concedeTournament}
/>
)
}
@ -466,6 +495,7 @@ function TeamPanel({
onRemovePlayer,
onAddExisting,
onAddNew,
onConcedeTournament,
}: {
team: Team
otherTeam: Team | null
@ -475,8 +505,23 @@ function TeamPanel({
onRemovePlayer: (teamId: string, rosterId: string) => void
onAddExisting: (teamId: string, playerId: string) => void
onAddNew: (teamId: string, name: string, handicap?: number) => void
onConcedeTournament: (teamId: string) => Promise<string | null>
}) {
const [confirmingId, setConfirmingId] = useState<string | null>(null)
const [confirmingGiveUp, setConfirmingGiveUp] = useState(false)
const [giveUpError, setGiveUpError] = useState<string | null>(null)
const [givingUp, setGivingUp] = useState(false)
async function handleGiveUp() {
setGivingUp(true)
const message = await onConcedeTournament(team.id)
setGivingUp(false)
if (message) {
setGiveUpError(message)
} else {
setConfirmingGiveUp(false)
}
}
return (
<section
@ -603,6 +648,57 @@ function TeamPanel({
onAddNew={onAddNew}
/>
</div>
<div className="border-t border-border p-4">
{confirmingGiveUp ? (
<div className="flex flex-col gap-3 rounded-2xl bg-destructive/5 p-3">
{giveUpError && (
<p role="alert" className="text-xs font-medium text-destructive text-pretty">
{giveUpError}
</p>
)}
<p className="text-sm text-foreground text-pretty">
Er du sikker at <span className="font-semibold">{team.name}</span> gir opp
resten av turneringen? Motstanderen får full poengsum for ALLE ikke-avgjorte
matcher, med én gang.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="h-9 rounded-xl"
disabled={givingUp}
onClick={handleGiveUp}
>
Gi opp turneringen
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-xl"
onClick={() => {
setConfirmingGiveUp(false)
setGiveUpError(null)
}}
>
Avbryt
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => setConfirmingGiveUp(true)}
className="h-9 w-full justify-start rounded-xl text-xs font-semibold text-muted-foreground hover:text-destructive"
>
<Flag aria-hidden="true" className="size-3.5" />
Gi opp resten av turneringen for laget (walkover)
</Button>
)}
</div>
</section>
)
}