diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index a8e4131..752ce26 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -178,7 +178,16 @@
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(1\\).zip\")",
"Bash(python3 -m zipfile -e \"tee-cup-login-screen \\(1\\).zip\" /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0_dashboard_export/)",
"Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
- "Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)"
+ "Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)",
+ "Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(2\\).zip\")",
+ "Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py)",
+ "Bash(cp .env .env.scratch)",
+ "Bash(sed -i -e 's#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#' -e 's#^TEECUP_DEV_LOG_MAGIC_LINKS=.*#TEECUP_DEV_LOG_MAGIC_LINKS=true#' .env.scratch)",
+ "Bash(echo \"TEECUP_DEV_LOG_MAGIC_LINKS=true\")",
+ "Bash(break)",
+ "Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)",
+ "Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)",
+ "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
diff --git a/CLAUDE.md b/CLAUDE.md
index 62ccbe5..6074293 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -329,12 +329,52 @@ Ferdig og verifisert:
dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første
gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra
frontend mot ekte produksjonsdata.
+- **Lag/roster-skjerm LIVE (2026-07-18):** tredje V0-skjerm
+ (`/tournaments/[id]`), samme re-eksport-mønster som dashboard-runden —
+ diffet mot live-treet, tok kun inn `tournament-detail.tsx` og et
+ `Link`-basert `tournament-card.tsx` (navigasjon fra dashbordet).
+ **URL-design bevisst avvikende fra V0s forslag:** V0s genererte side leste
+ aldri `params.id` og hadde ingen organization_id i det hele tatt — holdt
+ derfor V0s flate `/tournaments/[id]`-struktur (i stedet for en nøstet
+ `/orgs/[orgId]/tournaments/[id]`, som ville krevd manuell ombygging ved
+ HVER fremtidig V0-reeksport) og la `org`+`name` til som søkeparametre i
+ `tournament-card.tsx` sin lenke — API-et krever organization_id på alle
+ team-/roster-kall (RLS).
+ **Reelt hull funnet FØR integrering, ikke etter:** V0-skjermen bygger inn
+ "fjern spiller"/"gjør til kaptein"-handlinger, men backend hadde KUN
+ GET/POST på `team_roster` — ingen DELETE eller PATCH. Spurte bruker
+ eksplisitt (samme mønster som andre scope-avklaringer denne økten) — svar:
+ bygg de to endepunktene nå. Lagt til i `app/routers/tournaments.py`:
+ `PATCH .../roster/{roster_id}` (bevisst enkel — setter/fjerner
+ `is_captain` på NØYAKTIG denne raden, håndhever IKKE "kun én kaptein per
+ lag", siden kaptein fortsatt bare er et merke, ikke en egen
+ autorisasjonsrolle) og `DELETE .../roster/{roster_id}` (204, idempotent
+ `NOT_FOUND` ved dobbel sletting — ikke krasj).
+ **`tournament-detail.tsx` sitt datalag skrevet om fra V0s mock:** henter
+ lag + roster (roster-radene bærer allerede `display_name`/
+ `handicap_index_snapshot` fra APIet, så V0s separate `poolById`-oppslag
+ ble fjernet som overflødig) og organisasjonens spillerpool
+ (`GET /orgs/{id}/players`, brukt til type-ahead ved "legg til spiller").
+ Alle fem mutasjonene (opprett lag, legg til eksisterende spiller, opprett
+ ny spiller inline + rostre, endre kaptein, fjern fra roster) kablet mot
+ ekte endepunkter — presentasjonskomponentene (kort, type-ahead,
+ fargevelger, bekreft-fjerning) beholdt uendret fra V0.
+ **Verifisert:** de to nye endepunktene testet mot en fersk
+ `teecup_scratch` (PATCH setter kaptein + riktig `NOT_FOUND` på ugyldig id,
+ DELETE gir 204 + idempotent `NOT_FOUND` ved gjentak, `test_isolation.sql`
+ fortsatt 12/12), ekte typesjekket frontend-build (5 ruter), redeploy av
+ BEGGE containere (backend-endepunktene er nye), `teecup.teeoff.no/dashboard`
+ → 200, `teeoff.no` upåvirket. Selve skrive-flyten på `/tournaments/[id]`
+ (opprett lag/roster) ikke testet med ekte data i denne runden — venter på
+ brukeren, samme mønster som dashboard-rundens skrive-test.
Neste steg:
-1. Flere V0-skjermer (lag/roster, økt/program, blind draw, scorekort,
- leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt),
- FORVENT en full re-eksport neste gang også — diff mot live-treet i et
- scratch-område før noe pakkes ut over eksisterende filer (se
- dashboard-runden over for hvorfor dette er nødvendig hver gang).
+1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
+ 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 ikke støtter ennå FØR integrering (se
+ dashboard-/roster-rundene over for hvorfor begge er nødvendige hver
+ gang).
2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
3. Kommunikasjon (chat/feed) — ikke startet.
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index a206b21..9a37f5f 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -37,6 +37,8 @@
| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. |
| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. |
| Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). |
+| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
+| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
---
@@ -300,6 +302,10 @@
- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den
offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke
rettigheter (sette oppstilling), ikke bare være et flagg.
+- **Nytt 2026-07-18:** `PATCH .../roster/{id}` (sette/fjerne kaptein) håndhever
+ bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes
+ kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette
+ punktet, ikke løses isolert i roster-endepunktet.
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py
index 021a92f..4fb6077 100644
--- a/app/routers/tournaments.py
+++ b/app/routers/tournaments.py
@@ -252,6 +252,65 @@ async def add_roster_entry(
return RosterEntry(**dict(row))
+class RosterEntryUpdate(BaseModel):
+ is_captain: bool
+
+
+@router.patch(
+ "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
+ response_model=RosterEntry,
+)
+async def update_roster_entry(
+ team_id: str,
+ roster_id: str,
+ body: RosterEntryUpdate,
+ organization_id: str = Depends(get_authorized_org),
+) -> RosterEntry:
+ # Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden,
+ # håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et
+ # merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG).
+ async with org_connection(organization_id) as conn:
+ row = await conn.fetchrow(
+ """
+ WITH updated AS (
+ UPDATE team_roster
+ SET is_captain = $1
+ WHERE id = $2 AND team_id = $3
+ RETURNING id, player_id, handicap_index_snapshot, is_captain
+ )
+ SELECT updated.id::text, updated.player_id::text,
+ updated.handicap_index_snapshot::float AS handicap_index_snapshot,
+ updated.is_captain, p.display_name
+ FROM updated JOIN player p ON p.id = updated.player_id
+ """,
+ body.is_captain,
+ roster_id,
+ team_id,
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
+ return RosterEntry(**dict(row))
+
+
+@router.delete(
+ "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
+ status_code=204,
+)
+async def delete_roster_entry(
+ team_id: str,
+ roster_id: str,
+ organization_id: str = Depends(get_authorized_org),
+) -> None:
+ async with org_connection(organization_id) as conn:
+ deleted = await conn.fetchval(
+ "DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id",
+ roster_id,
+ team_id,
+ )
+ if deleted is None:
+ raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
+
+
# ---------------------------------------------------------------------------
# Økter (ADR-007: turnering = ordnet sekvens av økter)
# ---------------------------------------------------------------------------
diff --git a/frontend/app/tournaments/[id]/page.tsx b/frontend/app/tournaments/[id]/page.tsx
new file mode 100644
index 0000000..629ee03
--- /dev/null
+++ b/frontend/app/tournaments/[id]/page.tsx
@@ -0,0 +1,28 @@
+import { TournamentDetail } from "@/components/tournament-detail"
+
+export default async function TournamentPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string }>
+ searchParams: Promise<{ org?: string; name?: string }>
+}) {
+ const { id } = await params
+ const { org, name } = await searchParams
+ const tournamentName = name?.trim() || "Turnering"
+
+ // org-parameteren følger alltid med fra dashbordets TournamentCard-lenke
+ // (se components/tournament-card.tsx) -- API-et krever organization_id på
+ // alle team-/roster-endepunkter (RLS, se ADR-001/003).
+ if (!org) {
+ return (
+
+
+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
+
+
+ )
+ }
+
+ return
+}
diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx
index 48e380e..7b78cfd 100644
--- a/frontend/components/dashboard.tsx
+++ b/frontend/components/dashboard.tsx
@@ -328,7 +328,9 @@ function OrganizationView({
/>
) : tournaments.length > 0 ? (
- tournaments.map((t) => )
+ tournaments.map((t) => (
+
+ ))
) : (
)}
diff --git a/frontend/components/tournament-card.tsx b/frontend/components/tournament-card.tsx
index 15451b0..c739284 100644
--- a/frontend/components/tournament-card.tsx
+++ b/frontend/components/tournament-card.tsx
@@ -1,3 +1,4 @@
+import Link from "next/link"
import { Calendar, ChevronRight } from "lucide-react"
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
@@ -29,12 +30,18 @@ function formatDateRange(start?: string, end?: string) {
return from ?? to ?? null
}
-export function TournamentCard({ tournament }: { tournament: Tournament }) {
+export function TournamentCard({
+ tournament,
+ orgId,
+}: {
+ tournament: Tournament
+ orgId: string
+}) {
const dateRange = formatDateRange(tournament.startDate, tournament.endDate)
return (
-