diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d696a69..760a9a7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -224,7 +224,14 @@ "Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -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 /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": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index b55c388..4be5d55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -660,13 +660,59 @@ Ferdig og verifisert: utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er 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: -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). +1. Rulle program-skjermen live (venter på bekreftelse). +2. Flere V0-skjermer (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. 3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. 4. Kommunikasjon (chat/feed) — ikke startet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index bcefc8f..2779df4 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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å. | | 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. | +| 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) - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp diff --git a/app/main.py b/app/main.py index 7b1c11a..1790bb2 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI 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 @@ -34,6 +34,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan) app.include_router(auth.router) app.include_router(organizations.router) app.include_router(players.router) +app.include_router(courses.router) app.include_router(tournaments.router) app.include_router(matches.router) app.include_router(scoring.router) diff --git a/app/routers/courses.py b/app/routers/courses.py new file mode 100644 index 0000000..4417f60 --- /dev/null +++ b/app/routers/courses.py @@ -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)) diff --git a/frontend/app/tournaments/[id]/program/page.tsx b/frontend/app/tournaments/[id]/program/page.tsx new file mode 100644 index 0000000..c401d18 --- /dev/null +++ b/frontend/app/tournaments/[id]/program/page.tsx @@ -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 ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 18f7951..2448fff 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -244,6 +244,21 @@ export function TournamentDetail({ + +
diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx new file mode 100644 index 0000000..cbe5358 --- /dev/null +++ b/frontend/components/tournament-program.tsx @@ -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(["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 | 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 = { + foursome: "Foursome", + greensome: "Greensome", + scramble_2: "Scramble (2 spillere)", + scramble_4: "Scramble (4 spillere)", + fourball: "Fourball", + singles: "Singel", +} + +const HOLE_LABELS: Record = { + full_18: "18 hull", + front_9: "Front 9", + back_9: "Back 9", +} + +const SCORING_LABELS: Record = { + 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([]) + const [courses, setCourses] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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() + 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 { + 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 | 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 ( +
+
+
+ +
+ + +
+ +
+
+

Program

+

+ Sett opp turneringens økter i rekkefølge. Hver økt er én runde med et gitt format og + tidspunkt. +

+
+ + {error && ( +

+ {error} +

+ )} + + {loading ? ( +
+ + ) : ( + <> + {sorted.length === 0 ? ( + + ) : ( +
    + {sorted.map((session, index) => ( + + ))} +
+ )} + +
+ +
+ + )} +
+
+ ) +} + +// --- 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 ( +
  • +
    +
    + {session.sequence} +
    + {!isLast && + +
    +
    +

    {title}

    +
    + {FORMAT_LABELS[session.format]} + {HOLE_LABELS[session.hole_config]} + {SCORING_LABELS[session.scoring_mode]} + {formatPoints(session.points_per_match)} poeng + {course && {course.name}} +
    +
    + +
    + {session.scheduled_at ? ( +
    +
    + ) : ( +
    +
    + )} + {session.start_hole !== 1 && ( +
    +
    + )} +
    +
    +
  • + ) +} + +function MetaBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +// --- Empty state ----------------------------------------------------------- + +function EmptyState() { + return ( +
    +
    +
    +
    +

    Ingen økter ennå

    +

    + Legg til den første økten nedenfor for å bygge programmet. +

    +
    +
    + ) +} + +// --- 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 | null + }) => Promise +}) { + const [name, setName] = useState("") + const [format, setFormat] = useState("foursome") + const [holeConfig, setHoleConfig] = useState("full_18") + const [scoringMode, setScoringMode] = useState("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(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 | 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 = { + 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 ( +
    +
    +
    +
    +
    + + Økt {nextSequence} + + Legg til økt +
    +
    + +
    + + setName(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + +
    + +
    + {FORMAT_OPTIONS.map((opt) => ( + setFormat(opt)}> + {FORMAT_LABELS[opt]} + + ))} +
    +
    + +
    + + {selectedCourseId ? ( +
    + + {courses.find((c) => c.id === selectedCourseId)?.name} + + +
    + ) : ( +
    + setCourseQuery(e.target.value)} + className="h-12 rounded-2xl text-base" + /> + {trimmedCourseQuery.length > 0 && ( +
    +
      + {courseMatches.map((c) => ( +
    • + +
    • + ))} + {courseMatches.length === 0 && !showCreateCourse && ( +
    • Ingen treff.
    • + )} + {showCreateCourse && ( +
    • 0 ? "border-t border-border" : ""}> + +
    • + )} +
    +
    + )} +
    + )} +
    + +
    + +
    + {HOLE_OPTIONS.map((opt) => ( + setHoleConfig(opt)}> + {HOLE_LABELS[opt]} + + ))} +
    +
    + +
    + +
    + {SCORING_OPTIONS.map((opt) => ( + setScoringMode(opt)}> + {SCORING_LABELS[opt]} + + ))} +
    +
    + +
    +
    + + setPoints(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    +
    + + setStartHole(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    +
    + +
    + + setScheduledAt(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + + {hasTime && ( +
    + + setTeeInterval(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
    + )} + +
    + + + {advancedOpen && ( +
    + + + +
    + + setHcpPercent(e.target.value)} + className="h-11 rounded-xl text-base" + /> +
    +
    + )} +
    + + +
    + ) +} + +// --- Small UI helpers ------------------------------------------------------ + +function SegmentButton({ + selected, + onClick, + children, +}: { + selected: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function ToggleRow({ + label, + checked, + onCheckedChange, +}: { + label: string + checked: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( +
    + {label} + +
    + ) +} + +// --- 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", + }) +} diff --git a/frontend/components/ui/switch.tsx b/frontend/components/ui/switch.tsx new file mode 100644 index 0000000..9b8b44b --- /dev/null +++ b/frontend/components/ui/switch.tsx @@ -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 ( + + + + ) +} + +export { Switch } diff --git a/frontend/tee-cup-login-screen (4).zip b/frontend/tee-cup-login-screen (4).zip new file mode 100644 index 0000000..01bebd3 Binary files /dev/null and b/frontend/tee-cup-login-screen (4).zip differ diff --git a/frontend/tee-cup-login-screen (5).zip b/frontend/tee-cup-login-screen (5).zip new file mode 100644 index 0000000..c0059ae Binary files /dev/null and b/frontend/tee-cup-login-screen (5).zip differ