diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 389fa25..02fd08b 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -257,7 +257,11 @@
"Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)",
"Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/del_cookies.txt /tmp/captvis_cookies.txt)",
- "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)"
+ "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)",
+ "Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py /opt/teecup/app/routers/scoring.py)",
+ "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/score_cookies.txt)",
+ "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-7)",
+ "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/sc_cookies.txt)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
diff --git a/CLAUDE.md b/CLAUDE.md
index 95df62d..79e8007 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -880,11 +880,53 @@ Ferdig og verifisert:
**Rullet ut live**, bruker bekreftet eksplisitt: begge containere
bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket.
+- **Backend-forarbeid for scorekort-skjermen, LIVE (2026-07-18):** to hull
+ funnet ved gjennomlesing av `scoring.py` FØR V0-prompten ble skrevet,
+ samme føre-var-mønster som resten av økten.
+ 1. **Hull-/stroke-index-data for egendefinerte baner** (bevisst utsatt fra
+ tee-rundens status-notat) — `hole`-tabellen har ALDRI hatt noen vei inn
+ for custom-baner, som blokkerte `stroke`-scoringsmodus helt (`hole_
+ result`-modus trenger den ikke). Ny `GET/POST /orgs/{id}/courses/{id}/
+ holes` i `app/routers/courses.py` — engangs alle-18-på-en-gang
+ (avviser feil antall, avviser hvis banen allerede har hull, avviser på
+ offisielle baner som får hullene sine fra teeoff-import).
+ 2. **`GET scorecard` viste kun UTLEDET vinn/tap/delt per hull, aldri de
+ faktiske tallene som var registrert** — umulig å bygge en skjerm som
+ viser gjeldende tilstand ved gjenlasting. Utvidet `Scorecard`-modellen
+ i `app/routers/scoring.py` med `stroke_entries`/`hole_result_entries`
+ (rå `hole_score`/`match_hole_result`-rader, nøyaktig ett av de to fylt
+ ut avhengig av øktens scoring_mode).
+ **Verifisert grundig i scratch:** hull-validering (feil antall avvist,
+ gjentatt oppsett avvist), og en FULL `stroke`-modus scoringsrunde med
+ ekte handicap-justert nettoberegning på en egendefinert bane for FØRSTE
+ gang noensinne (ulikt slagtall — 4 mot 5 — ga korrekt "halved" etter
+ handicap-utjevning), scorecard sin nye `stroke_entries` bekreftet å
+ returnere nøyaktig det som ble registrert. `test_isolation.sql` 12/12.
+ **Rullet ut live**, ingen migrasjon, kun `teecup_api` redeployet,
+ `teeoff.no` upåvirket.
+
+- **Scorekort-skjermen LIVE (2026-07-18):** åttende V0-skjerm,
+ `components/session-scorecard.tsx`, ny rute `/tournaments/[id]/sessions/
+ [sessionId]/matches/[matchId]`. Ett hull i fokus om gangen (banebruk,
+ ikke et regneark) — store slag-steppere for `stroke`-modus, tre-valgs
+ vinner-knapper for `hole_result`-modus, hull-chip-navigasjon, kollapsbar
+ full oversikt, feiret "avgjort"-banner som låser alt til read-only.
+ Blind draw sin avslørte visning lenker nå til hver match sitt scorekort.
+ **Ingen nye backend-hull denne runden** — forrige rundes forarbeid
+ (hull-endepunkter + `stroke_entries`/`hole_result_entries` i scorecard)
+ dekket akkurat det skjermen trengte.
+ **Verifisert grundig i scratch, inkludert et fullt oppsett som speiler
+ NØYAKTIG frontend-ens egen last-sekvens** (sessions→teams→matches→
+ holes→scorecard, deretter en ekte `POST hole-scores` for begge spillere
+ på hull 1 og en refetch): status_text/derivert resultat oppdaterte seg
+ korrekt ("1 UP (A)", hull 1 → "a"), alle feltnavn stemte eksakt med
+ TypeScript-typene uten justering. `test_isolation.sql` 12/12, ekte
+ typesjekket build.
+ **Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket.
+
Neste steg:
-1. Scorekort-skjermen (neste i "bygg i rekkefølgen ting brukes") — her må
- også hull-/stroke-index-data for egendefinerte baner løses (bevisst
- utsatt fra tee-rundens tidligere status-notat). Deretter leaderboard.
- Samme mønster:
+1. Leaderboard-skjermen (siste i "bygg i rekkefølgen ting brukes"-serien
+ for match-play-flyten). Samme mønster:
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver
gang — diff mot live-treet i et scratch-område før noe pakkes ut over
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index e92971f..9fd5d37 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -400,6 +400,18 @@
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
(`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
`hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
+- **Nytt 2026-07-18, ✅ BYGGET (forarbeid før scorekort-skjermen):**
+ `GET/POST /orgs/{id}/courses/{id}/holes` løser hull-/stroke-index-hullet
+ fra tee-runden (kun `stroke`-modus trengte det). `GET scorecard` utvidet
+ med `stroke_entries`/`hole_result_entries` (rå registrerte tall, ikke
+ bare utledet vinn/tap/delt) — uten dette kunne ikke en gjenlastet
+ scorekort-side vise gjeldende tilstand. Verifisert med en full
+ `stroke`-runde med ekte handicap-justering på en egendefinert bane.
+- **Frontend LIVE 2026-07-18:** `/tournaments/[id]/sessions/[sessionId]/
+ matches/[matchId]`, `components/session-scorecard.tsx`. Ett hull om
+ gangen med store steppere/knapper (banebruk i sollys, ikke et regneark
+ — matcher UX-visjonen lenger ned i denne fila). Lenket fra blind draw
+ sin avslørte visning. Ingen nye backend-hull denne runden.
### Bøtekasse (Kangaroo Court)
- **Status:** 📋 planlagt
diff --git a/app/routers/courses.py b/app/routers/courses.py
index 4ac8bd0..3bf8979 100644
--- a/app/routers/courses.py
+++ b/app/routers/courses.py
@@ -167,6 +167,89 @@ async def create_tee(
)
+# --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i --
+# scoring-fasen -- se app/routers/scoring.py sin bruk av `hole`-tabellen) -
+#
+# Egendefinerte baner har ALDRI hatt noen vei til hull-data, uavhengig av
+# ADR-019 (samme klasse hull som tee-mangelen -- flagget FØR scorekort-
+# skjermen ble bygget, ikke oppdaget for sent). Kun `stroke`-scoringsmodus
+# trenger dette; `hole_result`-modus fungerer allerede uten. Engangs
+# oppsett: enten alle 18 hull settes i ÉN innsending, eller ingen -- unngår
+# kompleksiteten med delvise/partielle hull-sett.
+
+
+class HoleCreate(BaseModel):
+ hole_number: int = Field(ge=1, le=18)
+ par: int = Field(ge=3, le=6)
+ stroke_index: int = Field(ge=1, le=18)
+
+
+class Hole(BaseModel):
+ hole_number: int
+ par: int
+ stroke_index: int
+
+
+@router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole])
+async def list_holes(
+ course_id: str,
+ organization_id: str = Depends(get_authorized_org),
+) -> list[Hole]:
+ async with org_connection(organization_id) as conn:
+ rows = await conn.fetch(
+ "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
+ course_id,
+ )
+ return [Hole(**dict(r)) for r in rows]
+
+
+@router.post("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole], status_code=201)
+async def create_holes(
+ course_id: str,
+ body: list[HoleCreate],
+ organization_id: str = Depends(get_authorized_org),
+) -> list[Hole]:
+ if len(body) != 18:
+ raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull i én innsending.")
+ numbers = sorted(h.hole_number for h in body)
+ if numbers != list(range(1, 19)):
+ raise app_error(400, "VALIDATION_FAILED", "Hullnumrene må dekke nøyaktig 1-18, hver én gang.")
+
+ async with org_connection(organization_id) as conn, translate_db_errors():
+ source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id)
+ if source is None:
+ raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
+ if source == "official":
+ raise app_error(
+ 400,
+ "VALIDATION_FAILED",
+ "Offisielle baner får hullene sine fra teeoff -- kan ikke legges til manuelt.",
+ )
+ existing = await conn.fetchval("SELECT count(*) FROM hole WHERE course_id = $1", course_id)
+ if existing > 0:
+ raise app_error(
+ 409, "DUPLICATE", "Banen har allerede registrert hull -- kan ikke settes opp på nytt."
+ )
+
+ rows = []
+ for h in body:
+ rows.append(
+ await conn.fetchrow(
+ """
+ INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING hole_number, par, stroke_index
+ """,
+ organization_id,
+ course_id,
+ h.hole_number,
+ h.par,
+ h.stroke_index,
+ )
+ )
+ return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
+
+
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------
diff --git a/app/routers/scoring.py b/app/routers/scoring.py
index 8037501..a689a27 100644
--- a/app/routers/scoring.py
+++ b/app/routers/scoring.py
@@ -401,6 +401,12 @@ class Scorecard(BaseModel):
points_side_a: float | None
points_side_b: float | None
holes: list[ScorecardHole]
+ # Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som
+ # faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet
+ # (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de
+ # to feltene er fylt ut, avhengig av øktens scoring_mode.
+ stroke_entries: list[HoleScoreOut] | None = None
+ hole_result_entries: list[HoleResultOut] | None = None
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
@@ -430,11 +436,35 @@ async def get_scorecard(
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
results = await _compute_hole_results(conn, match_id, match)
+ stroke_entries = None
+ hole_result_entries = None
+ if match["scoring_mode"] == "stroke":
+ rows = await conn.fetch(
+ """
+ SELECT hole_number, team_side::text AS team_side,
+ match_participant_id::text AS match_participant_id, gross_strokes
+ FROM hole_score WHERE match_id = $1 ORDER BY hole_number
+ """,
+ match_id,
+ )
+ stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
+ else:
+ rows = await conn.fetch(
+ """
+ SELECT hole_number, winning_side::text AS winning_side
+ FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
+ """,
+ match_id,
+ )
+ hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
+
played = _played_hole_numbers(match["hole_config"])
holes = [
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
]
return Scorecard(
+ stroke_entries=stroke_entries,
+ hole_result_entries=hole_result_entries,
match_id=match["id"],
status_text=match["status_text"],
points_side_a=match["points_side_a"],
diff --git a/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx b/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx
new file mode 100644
index 0000000..9a42771
--- /dev/null
+++ b/frontend/app/tournaments/[id]/sessions/[sessionId]/matches/[matchId]/page.tsx
@@ -0,0 +1,35 @@
+import { SessionScorecard } from "@/components/session-scorecard"
+
+export default async function SessionScorecardPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string; sessionId: string; matchId: string }>
+ searchParams: Promise<{ org?: string; name?: string }>
+}) {
+ const { id, sessionId, matchId } = await params
+ const { org, name } = await searchParams
+ const tournamentName = name?.trim() || "Turnering"
+
+ // Samme grunn som de andre turnering-undersidene: API-et krever
+ // organization_id på alle match-/scoring-endepunkter (RLS, se ADR-001/003).
+ if (!org) {
+ return (
+
+
+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
+