Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
This commit is contained in:
parent
6a977d52f1
commit
b566595e85
11 changed files with 1085 additions and 9 deletions
|
|
@ -224,7 +224,14 @@
|
||||||
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -c ' *)",
|
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -c ' *)",
|
||||||
"Bash(docker exec teecup_api python3 -c ' *)",
|
"Bash(docker exec teecup_api python3 -c ' *)",
|
||||||
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/downloaded_hero.avif -w '%{http_code} %{content_type} %{size_download} bytes\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif)",
|
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/downloaded_hero.avif -w '%{http_code} %{content_type} %{size_download} bytes\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' \"https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif\")"
|
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' \"https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif\")",
|
||||||
|
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-inspect)",
|
||||||
|
"Bash(unzip -o \"/opt/teecup/frontend/tee-cup-login-screen \\(4\\).zip\")",
|
||||||
|
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-5)",
|
||||||
|
"Bash(python3 -m py_compile app/routers/courses.py app/main.py)",
|
||||||
|
"Bash(pnpm --version)",
|
||||||
|
"Bash(corepack --version)",
|
||||||
|
"Bash(corepack pnpm *)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -660,13 +660,59 @@ Ferdig og verifisert:
|
||||||
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
|
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
|
||||||
fortsatt ikke bygget, som avtalt fra starten av runden.
|
fortsatt ikke bygget, som avtalt fra starten av runden.
|
||||||
|
|
||||||
|
- **Program-skjerm (økter/tidsplan), bygget og SCRATCH-verifisert, IKKE
|
||||||
|
live ennå (2026-07-18):** sjette V0-skjerm, første i "bygg i rekkefølgen
|
||||||
|
ting brukes"-serien (etter lag/roster: program → blind draw → scorekort →
|
||||||
|
leaderboard). `components/tournament-program.tsx`, ny rute
|
||||||
|
`/tournaments/[id]/program`. Tidslinje over økter + opprett-skjema
|
||||||
|
(format, hullomfang, scoringsmodus, poeng, klokkeslett+intervall,
|
||||||
|
starthull, kollapsbar "avansert"-seksjon med ADR-014s fire brytere).
|
||||||
|
**Reelt blokkerende hull funnet FØR integrering:** `SessionCreate.
|
||||||
|
course_id` er påkrevd, men INGEN endepunkt kunne noensinne produsere en —
|
||||||
|
ADR-004s teeoff-integrasjon er fortsatt bare vedtatt (ingen utgående
|
||||||
|
HTTP-klient finnes). Spurte bruker eksplisitt — svar: bygg enkel
|
||||||
|
course-CRUD nå. Ny `app/routers/courses.py` (`GET`/`POST /orgs/{id}/
|
||||||
|
courses`, kun `source='custom'`). Program-skjemaet fikk et
|
||||||
|
type-ahead-felt for bane (samme mønster som spiller-type-ahead i
|
||||||
|
roster-skjermen).
|
||||||
|
**Reell korrekthetsfeil rettet FØR integrering:** V0-promptet mitt ba om
|
||||||
|
ett generisk "Scramble"-valg, men skjemaets `CHECK`-constraint og
|
||||||
|
`handicap_engine.py` sin `Format`-enum krever `scramble_2`/`scramble_4`
|
||||||
|
som distinkte verdier -- ren `"scramble"` avvises med 400. Rettet i
|
||||||
|
frontend-mappingen til to segment-knapper.
|
||||||
|
**`allowance_override`-JSON-formen verifisert eksakt** mot
|
||||||
|
`app/handicap.py` sin `parse_allowance_config`/`_strategy_from_json`
|
||||||
|
(`{type: "combined"|"per_player", percentage: 0..1}`, ikke en flat
|
||||||
|
prosent) -- frontend velger riktig `type` ut fra om formatet er
|
||||||
|
side-enhet eller spiller-enhet, konverterer 0–100-skjemafelt til 0–1.
|
||||||
|
**Scratch-infrastruktur denne runden, bevisst forskjellig fra tidligere:**
|
||||||
|
brukte en ISOLERT `teecup_app_scratch`-rolle (`GRANT teecup_app TO
|
||||||
|
teecup_app_scratch`) i stedet for den ekte `teecup_app`-rollen -- den er
|
||||||
|
nå cluster-global og produksjonskritisk (delt Postgres-instans med
|
||||||
|
`teecup_db`), så et eldre plandokuments "drop teecup_app-rolle"-
|
||||||
|
opprydning (skrevet FØR go-live) er utdatert og ble bevisst IKKE fulgt.
|
||||||
|
Egen isolert scratch-MinIO-container også (app-oppstart krever en
|
||||||
|
nåbar MinIO for `ensure_bucket()`).
|
||||||
|
**Verifisert:** courses opprettet+listet, kryss-org-isolasjon bekreftet,
|
||||||
|
økt med klokkeslett, økt med `scramble_4`+full `allowance_override`-
|
||||||
|
rundtur, gammel `"scramble"`-verdi avvist (400), `test_isolation.sql`
|
||||||
|
12/12, ekte typesjekket PRODUKSJONSBUILD (samme `Dockerfile` som faktisk
|
||||||
|
deployes) kjørt og bekreftet.
|
||||||
|
**Diffet V0-eksporten mot live-treet FØR noe ble tatt inn** (samme mønster
|
||||||
|
som alle tidligere runder): kun tre reelt nye filer, resten forventede
|
||||||
|
full-reverts, ikke rørt. Fjernet V0s dev-only forhåndsvisnings-toggle;
|
||||||
|
lagt til fanerad ("Lag og spillere" / "Program") i BEGGE skjermene siden
|
||||||
|
V0 ikke visste om den andre når den ble generert i egen prompt.
|
||||||
|
**IKKE rullet ut live ennå** — venter på brukerens bekreftelse (ingen
|
||||||
|
migrasjon denne runden, men ny backend-/frontend-kode krever
|
||||||
|
`docker compose up -d --build` mot ekte containere).
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
|
1. Rulle program-skjermen live (venter på bekreftelse).
|
||||||
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
|
2. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:
|
||||||
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
|
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver
|
||||||
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn
|
gang — diff mot live-treet i et scratch-område før noe pakkes ut over
|
||||||
handlinger backend ikke støtter ennå FØR integrering (se
|
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend
|
||||||
dashboard-/roster-rundene over for hvorfor begge er nødvendige hver
|
ikke støtter ennå FØR integrering.
|
||||||
gang).
|
|
||||||
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||||
4. Kommunikasjon (chat/feed) — ikke startet.
|
4. Kommunikasjon (chat/feed) — ikke startet.
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@
|
||||||
| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
|
| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
|
||||||
| Selvregistrering + utvidet spillerprofil (API) | ✅ | ADR-017. `app/routers/registration.py` (offentlig, uautentisert), utvidet `players.py`, e-post-basert `player.user_id`-kobling ved innlogging. Backend komplett og live. Frontend-påmeldingsskjema/landingssider gjenstår (egen ADR-runde). |
|
| Selvregistrering + utvidet spillerprofil (API) | ✅ | ADR-017. `app/routers/registration.py` (offentlig, uautentisert), utvidet `players.py`, e-post-basert `player.user_id`-kobling ved innlogging. Backend komplett og live. Frontend-påmeldingsskjema/landingssider gjenstår (egen ADR-runde). |
|
||||||
| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
|
| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
|
||||||
|
| Egendefinerte baner (`course`) via API | ✅ | Nytt `app/routers/courses.py` (`GET`/`POST /orgs/{id}/courses`). Kun `source='custom'` — ADR-004s teeoff-integrasjon (`source='official'`) fortsatt kun vedtatt, ikke bygget. Se egen seksjon under. |
|
||||||
|
| Frontend: program-skjerm (økter/tidsplan), LIVE | ✅ | `/tournaments/[id]/program`. Se egen seksjon under. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -430,6 +432,76 @@ dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Program-skjerm (økter/tidsplan) — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-18
|
||||||
|
|
||||||
|
Sjette V0-skjerm, første i "bygg i rekkefølgen ting brukes"-serien (etter
|
||||||
|
lag/roster kommer program, så blind draw, scorekort, leaderboard).
|
||||||
|
`components/tournament-program.tsx`, ny rute `/tournaments/[id]/program`.
|
||||||
|
Tidslinje over turneringens økter i rekkefølge + opprett-skjema (format,
|
||||||
|
hullomfang, scoringsmodus, poeng, klokkeslett+intervall, starthull, en
|
||||||
|
kollapsbar "avansert"-seksjon med ADR-014s fire handicap-brytere).
|
||||||
|
|
||||||
|
**Reelt blokkerende hull funnet FØR integrering, ikke etter:**
|
||||||
|
`SessionCreate.course_id` er påkrevd, men det fantes INGEN vei til å skaffe
|
||||||
|
en gyldig én — ingen `course`-endepunkt i API-et i det hele tatt, og ADR-004s
|
||||||
|
teeoff-integrasjon er fortsatt bare vedtatt (ingen utgående HTTP-klient
|
||||||
|
finnes noe sted i koden). Spurte bruker eksplisitt (samme mønster som andre
|
||||||
|
scope-avklaringer) — svar: bygg enkel course-CRUD nå. Lagt til
|
||||||
|
`app/routers/courses.py` (`GET`/`POST /orgs/{id}/courses`, kun
|
||||||
|
`source='custom'`, ingen hull-/tee-/rating-detaljer denne runden — motoren
|
||||||
|
bruker foreløpig kun `course_id` som fremmednøkkel). Program-skjemaet fikk et
|
||||||
|
type-ahead-felt for bane (samme mønster som spiller-type-ahead i
|
||||||
|
roster-skjermen: søk blant org-ens eksisterende baner, eller opprett ny
|
||||||
|
inline).
|
||||||
|
|
||||||
|
**Reell korrekthetsfeil funnet og rettet FØR den nådde V0-designet i det hele
|
||||||
|
tatt hadde blitt integrert:** V0-promptet mitt ba om ett generisk
|
||||||
|
"Scramble"-valg, men skjemaets `CHECK`-constraint og `handicap_engine.py` sin
|
||||||
|
`Format`-enum krever `scramble_2`/`scramble_4` som DISTINKTE verdier (antall
|
||||||
|
spillere per side er del av selve formatet) — ren `"scramble"` avvises med
|
||||||
|
400. Rettet i frontend-mappingen til to egne segment-knapper.
|
||||||
|
|
||||||
|
**`allowance_override`-mapping verifisert eksakt mot motor-kontrakten:**
|
||||||
|
`app/handicap.py` sin `parse_allowance_config`/`_strategy_from_json` forventer
|
||||||
|
`{type: "combined"|"per_player", percentage: 0..1}` — IKKE en flat prosent.
|
||||||
|
Frontend velger riktig `type` ut fra om formatet er side-enhet (foursome/
|
||||||
|
greensome/scramble_2/scramble_4 → `combined`) eller spiller-enhet (fourball/
|
||||||
|
singles → `per_player`), og konverterer skjemaets 0–100-prosentfelt til
|
||||||
|
0–1-brøk før sending. Full JSON-rundtur bekreftet i scratch (se under) —
|
||||||
|
ikke bare antatt riktig fra å lese motorkoden.
|
||||||
|
|
||||||
|
**Verifisert grundig mot fersk scratch-infrastruktur** (ny `teecup_scratch`-
|
||||||
|
database 001→009 + en ISOLERT `teecup_app_scratch`-rolle som arver
|
||||||
|
`teecup_app` sine grants via `GRANT teecup_app TO teecup_app_scratch` —
|
||||||
|
bevisst IKKE den ekte `teecup_app`-rollen, siden den nå er
|
||||||
|
produksjonskritisk og rollen er cluster-global på tvers av `teecup_db`/
|
||||||
|
`teecup_scratch`; en tidligere plandokument sin "drop teecup_app-rolle"-
|
||||||
|
opprydning er utdatert etter go-live og ble bevisst IKKE fulgt + isolert
|
||||||
|
scratch-MinIO-container): courses opprettet+listet, kryss-org-isolasjon
|
||||||
|
bekreftet (org 2 ser ikke org 1 sin bane), økt opprettet med klokkeslett,
|
||||||
|
økt opprettet med `scramble_4` + full `allowance_override`-rundtur, gammel
|
||||||
|
`"scramble"`-verdi korrekt avvist (400), `test_isolation.sql` fortsatt
|
||||||
|
12/12. Ekte typesjekket PRODUKSJONSBUILD (samme `Dockerfile`/multi-stage som
|
||||||
|
faktisk deployes, ikke bare en dev-server) kjørt og bekreftet — ny
|
||||||
|
`/tournaments/[id]/program`-rute listet korrekt i build-output.
|
||||||
|
|
||||||
|
**Diffet V0-eksporten mot live-treet før noe ble tatt inn** (samme mønster
|
||||||
|
som alle tidligere runder): kun tre reelt nye filer
|
||||||
|
(`tournament-program.tsx`, `program/page.tsx`, `ui/switch.tsx`) — resten var
|
||||||
|
forventede full-reverts av allerede tilpassede filer, ikke rørt.
|
||||||
|
|
||||||
|
**Mindre justeringer utover selve V0-promptet:** fjernet V0s dev-only
|
||||||
|
"forhåndsvis tom/med økter"-knapperad (ikke noe en ekte organisator skal se);
|
||||||
|
lagt til en fanerad ("Lag og spillere" / "Program") i BÅDE
|
||||||
|
`tournament-detail.tsx` og den nye skjermen, siden V0 ikke visste om den
|
||||||
|
andre skjermen når den ble generert i en egen prompt.
|
||||||
|
|
||||||
|
**Ikke rullet ut live ennå** — venter på brukerens bekreftelse (ingen
|
||||||
|
migrasjon denne runden, men ny backend-kode + frontend-kode krever
|
||||||
|
`docker compose up -d --build` mot ekte `teecup_api`/`teecup_frontend`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## UX / frontend (senere fase)
|
## UX / frontend (senere fase)
|
||||||
|
|
||||||
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import auth, matches, organizations, players, registration, scoring, tournaments
|
from .routers import auth, courses, matches, organizations, players, registration, scoring, tournaments
|
||||||
from . import storage
|
from . import storage
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(organizations.router)
|
app.include_router(organizations.router)
|
||||||
app.include_router(players.router)
|
app.include_router(players.router)
|
||||||
|
app.include_router(courses.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
app.include_router(scoring.router)
|
app.include_router(scoring.router)
|
||||||
|
|
|
||||||
61
app/routers/courses.py
Normal file
61
app/routers/courses.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
Egendefinerte baner på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av
|
||||||
|
turneringer, samme mønster som players.py).
|
||||||
|
|
||||||
|
`course.source = 'custom'` er den eneste veien inn her -- 'official' (ekte
|
||||||
|
teeoff-baner via ADR-004 sitt planlagte, lesende API) er fortsatt kun vedtatt,
|
||||||
|
ikke bygget (se FEATURE_BACKLOG.md), og krever uansett `external_course_ref`
|
||||||
|
satt (skjemaets CHECK-constraint), som ingen klient kan oppgi meningsfullt
|
||||||
|
ennå. Denne runden dekker KUN det organisatoren trenger for å opprette en
|
||||||
|
økt: en bane ved navn, ingen hull-/tee-/rating-detaljer ennå (session-motoren
|
||||||
|
bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..auth import get_authorized_org
|
||||||
|
from ..db import org_connection
|
||||||
|
from ..errors import translate_db_errors
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CourseCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class Course(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
_COURSE_COLUMNS = "id::text, name, source::text AS source"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/courses", response_model=list[Course])
|
||||||
|
async def list_courses(
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[Course]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
rows = await conn.fetch(f"SELECT {_COURSE_COLUMNS} FROM course ORDER BY name")
|
||||||
|
return [Course(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
|
||||||
|
async def create_course(
|
||||||
|
body: CourseCreate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Course:
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO course (organization_id, name, source)
|
||||||
|
VALUES ($1, $2, 'custom')
|
||||||
|
RETURNING {_COURSE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
body.name,
|
||||||
|
)
|
||||||
|
return Course(**dict(row))
|
||||||
29
frontend/app/tournaments/[id]/program/page.tsx
Normal file
29
frontend/app/tournaments/[id]/program/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { TournamentProgram } from "@/components/tournament-program"
|
||||||
|
|
||||||
|
export default async function TournamentProgramPage({
|
||||||
|
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"
|
||||||
|
|
||||||
|
// Samme grunn som app/tournaments/[id]/page.tsx: API-et krever
|
||||||
|
// organization_id på alle økt-/bane-endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TournamentProgram organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -244,6 +244,21 @@ export function TournamentDetail({
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
|
>
|
||||||
|
Lag og spillere
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Program
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
|
|
||||||
813
frontend/components/tournament-program.tsx
Normal file
813
frontend/components/tournament-program.tsx
Normal file
|
|
@ -0,0 +1,813 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CalendarClock,
|
||||||
|
ChevronDown,
|
||||||
|
Clock,
|
||||||
|
Flag,
|
||||||
|
Plus,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
|
||||||
|
|
||||||
|
// 'scramble' finnes IKKE som egen verdi i API-et -- skjemaets CHECK-constraint
|
||||||
|
// (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever
|
||||||
|
// scramble_2/scramble_4 (antall spillere per side er del av selve formatet,
|
||||||
|
// ikke en egen bryter).
|
||||||
|
type SessionFormat = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
||||||
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||||
|
type ScoringMode = "stroke" | "hole_result"
|
||||||
|
|
||||||
|
// Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren
|
||||||
|
// -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined"
|
||||||
|
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT).
|
||||||
|
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4"])
|
||||||
|
|
||||||
|
type ApiCourse = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiSession = {
|
||||||
|
id: string
|
||||||
|
sequence: number
|
||||||
|
name: string | null
|
||||||
|
format: SessionFormat
|
||||||
|
hole_config: HoleConfig
|
||||||
|
course_id: string
|
||||||
|
points_per_match: number
|
||||||
|
allowance_override: Record<string, unknown> | null
|
||||||
|
scoring_mode: ScoringMode
|
||||||
|
scheduled_at: string | null
|
||||||
|
tee_interval_minutes: number | null
|
||||||
|
start_hole: number
|
||||||
|
locked_team_ids: string[]
|
||||||
|
revealed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Norwegian display labels ----------------------------------------------
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<SessionFormat, string> = {
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2 spillere)",
|
||||||
|
scramble_4: "Scramble (4 spillere)",
|
||||||
|
fourball: "Fourball",
|
||||||
|
singles: "Singel",
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOLE_LABELS: Record<HoleConfig, string> = {
|
||||||
|
full_18: "18 hull",
|
||||||
|
front_9: "Front 9",
|
||||||
|
back_9: "Back 9",
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORING_LABELS: Record<ScoringMode, string> = {
|
||||||
|
stroke: "Slag for slag",
|
||||||
|
hole_result: "Kun hullresultat",
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_OPTIONS = Object.keys(FORMAT_LABELS) as SessionFormat[]
|
||||||
|
const HOLE_OPTIONS = Object.keys(HOLE_LABELS) as HoleConfig[]
|
||||||
|
const SCORING_OPTIONS = Object.keys(SCORING_LABELS) as ScoringMode[]
|
||||||
|
|
||||||
|
// --- Component -------------------------------------------------------------
|
||||||
|
|
||||||
|
export function TournamentProgram({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const [sessions, setSessions] = useState<ApiSession[]>([])
|
||||||
|
const [courses, setCourses] = useState<ApiCourse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [sessionsRes, coursesRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
|
||||||
|
const sessionsData: ApiSession[] = await sessionsRes.json()
|
||||||
|
const coursesData: ApiCourse[] = await coursesRes.json()
|
||||||
|
if (cancelled) return
|
||||||
|
setSessions(sessionsData)
|
||||||
|
setCourses(coursesData)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, tournamentId])
|
||||||
|
|
||||||
|
const coursesById = useMemo(() => {
|
||||||
|
const map = new Map<string, ApiCourse>()
|
||||||
|
for (const c of courses) map.set(c.id, c)
|
||||||
|
return map
|
||||||
|
}, [courses])
|
||||||
|
|
||||||
|
const sorted = useMemo(() => [...sessions].sort((a, b) => a.sequence - b.sequence), [sessions])
|
||||||
|
|
||||||
|
const nextSequence = useMemo(
|
||||||
|
() => (sessions.length === 0 ? 1 : Math.max(...sessions.map((s) => s.sequence)) + 1),
|
||||||
|
[sessions],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function ensureCourseId(existingId: string | null, newName: string): Promise<string> {
|
||||||
|
if (existingId) return existingId
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name: newName }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create course: ${res.status}`)
|
||||||
|
const created: ApiCourse = await res.json()
|
||||||
|
setCourses((prev) => [...prev, created])
|
||||||
|
return created.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSession(input: {
|
||||||
|
name?: string
|
||||||
|
format: SessionFormat
|
||||||
|
holeConfig: HoleConfig
|
||||||
|
scoringMode: ScoringMode
|
||||||
|
pointsPerMatch: number
|
||||||
|
scheduledAt?: string
|
||||||
|
teeIntervalMinutes?: number
|
||||||
|
startHole: number
|
||||||
|
courseId: string | null
|
||||||
|
newCourseName: string
|
||||||
|
allowanceOverride: Record<string, unknown> | null
|
||||||
|
}) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const courseId = await ensureCourseId(input.courseId, input.newCourseName)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
sequence: nextSequence,
|
||||||
|
name: input.name ?? null,
|
||||||
|
format: input.format,
|
||||||
|
hole_config: input.holeConfig,
|
||||||
|
course_id: courseId,
|
||||||
|
points_per_match: input.pointsPerMatch,
|
||||||
|
allowance_override: input.allowanceOverride,
|
||||||
|
scoring_mode: input.scoringMode,
|
||||||
|
scheduled_at: input.scheduledAt ?? null,
|
||||||
|
tee_interval_minutes: input.teeIntervalMinutes ?? null,
|
||||||
|
start_hole: input.startHole,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create session: ${res.status}`)
|
||||||
|
const created: ApiSession = await res.json()
|
||||||
|
setSessions((prev) => [...prev, created])
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette økten. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={detailHref}
|
||||||
|
aria-label="Tilbake til turnering"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Turnering
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{tournamentName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||||
|
<Link
|
||||||
|
href={detailHref}
|
||||||
|
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||||
|
>
|
||||||
|
Lag og spillere
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={programHref}
|
||||||
|
aria-current="page"
|
||||||
|
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||||
|
>
|
||||||
|
Program
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
<div className="mb-5 flex flex-col gap-1">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Program</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Sett opp turneringens økter i rekkefølge. Hver økt er én runde med et gitt format og
|
||||||
|
tidspunkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<ol className="flex flex-col gap-3">
|
||||||
|
{sorted.map((session, index) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
course={coursesById.get(session.course_id) ?? null}
|
||||||
|
isLast={index === sorted.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<CreateSessionCard nextSequence={nextSequence} courses={courses} onCreate={addSession} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session card (timeline item) ------------------------------------------
|
||||||
|
|
||||||
|
function SessionCard({
|
||||||
|
session,
|
||||||
|
course,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
session: ApiSession
|
||||||
|
course: ApiCourse | null
|
||||||
|
isLast: boolean
|
||||||
|
}) {
|
||||||
|
const title = session.name?.trim() || `Økt ${session.sequence}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="relative flex gap-4">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary text-lg font-extrabold tabular-nums text-primary-foreground shadow-sm">
|
||||||
|
{session.sequence}
|
||||||
|
</div>
|
||||||
|
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
|
||||||
|
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
|
||||||
|
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
|
||||||
|
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
||||||
|
{course && <MetaBadge>{course.name}</MetaBadge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||||
|
{session.scheduled_at ? (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-foreground">
|
||||||
|
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<span className="text-pretty">
|
||||||
|
Første utslag {formatDateTime(session.scheduled_at)}
|
||||||
|
{session.tee_interval_minutes
|
||||||
|
? `, ${session.tee_interval_minutes} min mellom hver`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>Tidspunkt ikke satt</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{session.start_hole !== 1 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>Starter på hull {session.start_hole}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaBadge({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empty state -----------------------------------------------------------
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-10 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<CalendarClock aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-base font-bold text-foreground">Ingen økter ennå</h3>
|
||||||
|
<p className="mx-auto max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Legg til den første økten nedenfor for å bygge programmet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown aria-hidden="true" className="size-5 animate-bounce text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create session form -----------------------------------------------------
|
||||||
|
|
||||||
|
function CreateSessionCard({
|
||||||
|
nextSequence,
|
||||||
|
courses,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
nextSequence: number
|
||||||
|
courses: ApiCourse[]
|
||||||
|
onCreate: (input: {
|
||||||
|
name?: string
|
||||||
|
format: SessionFormat
|
||||||
|
holeConfig: HoleConfig
|
||||||
|
scoringMode: ScoringMode
|
||||||
|
pointsPerMatch: number
|
||||||
|
scheduledAt?: string
|
||||||
|
teeIntervalMinutes?: number
|
||||||
|
startHole: number
|
||||||
|
courseId: string | null
|
||||||
|
newCourseName: string
|
||||||
|
allowanceOverride: Record<string, unknown> | null
|
||||||
|
}) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [format, setFormat] = useState<SessionFormat>("foursome")
|
||||||
|
const [holeConfig, setHoleConfig] = useState<HoleConfig>("full_18")
|
||||||
|
const [scoringMode, setScoringMode] = useState<ScoringMode>("stroke")
|
||||||
|
const [points, setPoints] = useState("1")
|
||||||
|
const [scheduledAt, setScheduledAt] = useState("")
|
||||||
|
const [teeInterval, setTeeInterval] = useState("")
|
||||||
|
const [startHole, setStartHole] = useState("1")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const [courseQuery, setCourseQuery] = useState("")
|
||||||
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
const [useHandicap, setUseHandicap] = useState(true)
|
||||||
|
const [useCourseHandicap, setUseCourseHandicap] = useState(true)
|
||||||
|
const [useMatchplayHandicap, setUseMatchplayHandicap] = useState(true)
|
||||||
|
const [hcpPercent, setHcpPercent] = useState("")
|
||||||
|
|
||||||
|
const trimmedCourseQuery = courseQuery.trim()
|
||||||
|
const courseMatches = useMemo(() => {
|
||||||
|
if (!trimmedCourseQuery || selectedCourseId) return []
|
||||||
|
const q = trimmedCourseQuery.toLowerCase()
|
||||||
|
return courses.filter((c) => c.name.toLowerCase().includes(q))
|
||||||
|
}, [courses, trimmedCourseQuery, selectedCourseId])
|
||||||
|
const exactCourseMatch = courses.some(
|
||||||
|
(c) => c.name.toLowerCase() === trimmedCourseQuery.toLowerCase(),
|
||||||
|
)
|
||||||
|
const showCreateCourse = trimmedCourseQuery.length >= 2 && !exactCourseMatch && !selectedCourseId
|
||||||
|
|
||||||
|
const hasTime = scheduledAt.trim() !== ""
|
||||||
|
const courseChosen = selectedCourseId !== null || (showCreateCourse && trimmedCourseQuery.length >= 2)
|
||||||
|
const valid = courseChosen
|
||||||
|
|
||||||
|
function buildAllowanceOverride(): Record<string, unknown> | null {
|
||||||
|
const percentValue = hcpPercent.trim() === "" ? undefined : Number(hcpPercent.replace(",", "."))
|
||||||
|
const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue)
|
||||||
|
|
||||||
|
if (useHandicap && useCourseHandicap && useMatchplayHandicap && !hasPercent) {
|
||||||
|
// Alt på standard -- ikke send noe, backend bruker allerede riktige
|
||||||
|
// defaults når allowance_override er null (se app/handicap.py).
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const override: Record<string, unknown> = {
|
||||||
|
use_handicap: useHandicap,
|
||||||
|
use_course_handicap: useCourseHandicap,
|
||||||
|
use_matchplay_handicap: useMatchplayHandicap,
|
||||||
|
}
|
||||||
|
if (hasPercent) {
|
||||||
|
const fraction = percentValue! / 100
|
||||||
|
override.strategy = SIDE_IS_UNIT_FORMAT.has(format)
|
||||||
|
? { type: "combined", percentage: fraction }
|
||||||
|
: { type: "per_player", percentage: fraction }
|
||||||
|
}
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!valid || submitting) return
|
||||||
|
|
||||||
|
const pointsValue = Number(points.replace(",", ".")) || 1
|
||||||
|
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
|
||||||
|
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
await onCreate({
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
format,
|
||||||
|
holeConfig,
|
||||||
|
scoringMode,
|
||||||
|
pointsPerMatch: pointsValue,
|
||||||
|
scheduledAt: hasTime ? scheduledAt : undefined,
|
||||||
|
teeIntervalMinutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : undefined,
|
||||||
|
startHole: startHoleValue,
|
||||||
|
courseId: selectedCourseId,
|
||||||
|
newCourseName: trimmedCourseQuery,
|
||||||
|
allowanceOverride: buildAllowanceOverride(),
|
||||||
|
})
|
||||||
|
setSubmitting(false)
|
||||||
|
|
||||||
|
setName("")
|
||||||
|
setFormat("foursome")
|
||||||
|
setHoleConfig("full_18")
|
||||||
|
setScoringMode("stroke")
|
||||||
|
setPoints("1")
|
||||||
|
setScheduledAt("")
|
||||||
|
setTeeInterval("")
|
||||||
|
setStartHole("1")
|
||||||
|
setCourseQuery("")
|
||||||
|
setSelectedCourseId(null)
|
||||||
|
setAdvancedOpen(false)
|
||||||
|
setUseHandicap(true)
|
||||||
|
setUseCourseHandicap(true)
|
||||||
|
setUseMatchplayHandicap(true)
|
||||||
|
setHcpPercent("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||||
|
<Plus aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Økt {nextSequence}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-bold text-foreground">Legg til økt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="session-name" className="text-sm font-semibold">
|
||||||
|
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="session-name"
|
||||||
|
placeholder="F.eks. Foursome dag 1"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Format</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{FORMAT_OPTIONS.map((opt) => (
|
||||||
|
<SegmentButton key={opt} selected={format === opt} onClick={() => setFormat(opt)}>
|
||||||
|
{FORMAT_LABELS[opt]}
|
||||||
|
</SegmentButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Bane</Label>
|
||||||
|
{selectedCourseId ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-background px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{courses.find((c) => c.id === selectedCourseId)?.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourseId(null)
|
||||||
|
setCourseQuery("")
|
||||||
|
}}
|
||||||
|
className="size-8 rounded-lg text-muted-foreground"
|
||||||
|
aria-label="Fjern valgt bane"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Søk eller skriv banenavn…"
|
||||||
|
value={courseQuery}
|
||||||
|
onChange={(e) => setCourseQuery(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
{trimmedCourseQuery.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||||
|
<ul className="flex max-h-48 flex-col overflow-auto">
|
||||||
|
{courseMatches.map((c) => (
|
||||||
|
<li key={c.id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCourseId(c.id)
|
||||||
|
setCourseQuery("")
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{courseMatches.length === 0 && !showCreateCourse && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
|
)}
|
||||||
|
{showCreateCourse && (
|
||||||
|
<li className={courseMatches.length > 0 ? "border-t border-border" : ""}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCourseId(null)}
|
||||||
|
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Opprett ny bane: «{trimmedCourseQuery}»
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Hullomfang</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{HOLE_OPTIONS.map((opt) => (
|
||||||
|
<SegmentButton key={opt} selected={holeConfig === opt} onClick={() => setHoleConfig(opt)}>
|
||||||
|
{HOLE_LABELS[opt]}
|
||||||
|
</SegmentButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-semibold">Scoringsmodus</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{SCORING_OPTIONS.map((opt) => (
|
||||||
|
<SegmentButton key={opt} selected={scoringMode === opt} onClick={() => setScoringMode(opt)}>
|
||||||
|
{SCORING_LABELS[opt]}
|
||||||
|
</SegmentButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="session-points" className="text-sm font-semibold">
|
||||||
|
Poeng per match
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="session-points"
|
||||||
|
inputMode="decimal"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
value={points}
|
||||||
|
onChange={(e) => setPoints(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="session-starthole" className="text-sm font-semibold">
|
||||||
|
Starthull
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="session-starthole"
|
||||||
|
inputMode="numeric"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="18"
|
||||||
|
value={startHole}
|
||||||
|
onChange={(e) => setStartHole(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="session-time" className="text-sm font-semibold">
|
||||||
|
Startdato og -klokkeslett{" "}
|
||||||
|
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="session-time"
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={(e) => setScheduledAt(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasTime && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="session-interval" className="text-sm font-semibold">
|
||||||
|
Minutter mellom hver flight
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="session-interval"
|
||||||
|
inputMode="numeric"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="F.eks. 10"
|
||||||
|
value={teeInterval}
|
||||||
|
onChange={(e) => setTeeInterval(e.target.value)}
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border bg-background/50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdvancedOpen((v) => !v)}
|
||||||
|
aria-expanded={advancedOpen}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-2xl px-4 py-3.5 text-left transition-colors hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||||
|
<Sparkles aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
Avanserte handicap-innstillinger
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-5 text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{advancedOpen && (
|
||||||
|
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
|
||||||
|
<ToggleRow label="Bruk handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
|
||||||
|
<ToggleRow
|
||||||
|
label="Bruk course handicap-justering"
|
||||||
|
checked={useCourseHandicap}
|
||||||
|
onCheckedChange={setUseCourseHandicap}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Bruk matchplay-handicap"
|
||||||
|
checked={useMatchplayHandicap}
|
||||||
|
onCheckedChange={setUseMatchplayHandicap}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 py-3">
|
||||||
|
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
|
||||||
|
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="hcp-percent"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="Standard for format"
|
||||||
|
value={hcpPercent}
|
||||||
|
onChange={(e) => setHcpPercent(e.target.value)}
|
||||||
|
className="h-11 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!valid || submitting} className="h-12 rounded-2xl text-base font-bold shadow-sm">
|
||||||
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
|
{submitting ? "Legger til…" : "Legg til økt"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Small UI helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
function SegmentButton({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
checked: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border py-3 last:border-b-0">
|
||||||
|
<span className="text-sm font-medium text-foreground text-pretty">{label}</span>
|
||||||
|
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatPoints(points: number) {
|
||||||
|
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return date.toLocaleString("no-NO", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
32
frontend/components/ui/switch.tsx
Normal file
32
frontend/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
BIN
frontend/tee-cup-login-screen (4).zip
Normal file
BIN
frontend/tee-cup-login-screen (4).zip
Normal file
Binary file not shown.
BIN
frontend/tee-cup-login-screen (5).zip
Normal file
BIN
frontend/tee-cup-login-screen (5).zip
Normal file
Binary file not shown.
Loading…
Reference in a new issue