diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 170aaa0..89e3dd0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -350,7 +350,12 @@ "Bash(sudo -n apt-get install -y poppler-utils)", "Bash(python3 -m pytest test_handicap_engine.py -q)", "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")", - "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)" + "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)", + "Bash(docker run -d --name teecup-minio-scratch --network teeoff_default \\\\ *)", + "Bash(docker run -d --name teecup_api_scratch --network teeoff_default \\\\ *)", + "Bash(docker rm -f teecup_api_scratch >/dev/null 2>&1 *)", + "Bash(python3 test_rounds_frontend_endpoints.py)", + "Bash(TEECUP_API_ORIGIN=http://localhost:8000 npx next build)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/app/routers/rounds.py b/app/routers/rounds.py index f5087ff..1658db3 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -80,6 +80,17 @@ class PersonalCourseOut(BaseModel): name: str +class TeeOption(BaseModel): + name: str + genders: list[Literal["m", "f"]] + + +class PersonalCourseDetail(BaseModel): + id: str + name: str + tees: list[TeeOption] + + @router.get("/personal-courses", response_model=list[PersonalCourseOut]) async def search_personal_courses( q: str = "", @@ -93,6 +104,35 @@ async def search_personal_courses( return [PersonalCourseOut(**dict(r)) for r in rows] +@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail) +async def get_personal_course( + personal_course_id: str, + user: CurrentUser = Depends(get_current_user), +) -> PersonalCourseDetail: + async with plain_connection() as conn: + course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id) + if course_row is None: + raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.") + tee_rows = await conn.fetch( + """ + SELECT t.name AS tee_name, r.gender + FROM personal_course_tee t + JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id + WHERE t.personal_course_id = $1 + ORDER BY t.name + """, + personal_course_id, + ) + tees: dict[str, list[str]] = {} + for r in tee_rows: + tees.setdefault(r["tee_name"], []).append(r["gender"]) + return PersonalCourseDetail( + id=course_row["id"], + name=course_row["name"], + tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()], + ) + + @router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201) async def create_personal_course( body: PersonalCourseCreate, @@ -140,6 +180,83 @@ async def create_personal_course( return PersonalCourseOut(**dict(course_row)) +# --------------------------------------------------------------------------- +# Offisiell bane fra teeoff (ADR-019/ADR-033 Beslutning C) -- IKKE org-scopet, +# ulikt courses.py sine tilsvarende endepunkter (frittstående runder har +# ingen organisasjon). Rent lese-søk, ingen import/persistering av banen. +# --------------------------------------------------------------------------- + +class OfficialFacility(BaseModel): + slug: str + name: str + city: str | None + county: str | None + + +class OfficialCourseOption(BaseModel): + teeoff_course_id: int + name: str + is_main_course: bool + tees: list[TeeOption] + + +class OfficialFacilityDetail(BaseModel): + slug: str + name: str + courses: list[OfficialCourseOption] + + +@router.get("/rounds/official-search", response_model=list[OfficialFacility]) +async def search_official_courses_for_round( + q: str = "", + user: CurrentUser = Depends(get_current_user), +) -> list[OfficialFacility]: + try: + facilities = await teeoff_client.search_facilities(q) + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.") + return [ + OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county")) + for f in facilities + ] + + +@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail) +async def get_official_facility_for_round( + slug: str, + user: CurrentUser = Depends(get_current_user), +) -> OfficialFacilityDetail: + try: + facility = await teeoff_client.get_facility(slug) + except teeoff_client.TeeoffNotFoundError: + raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.") + except teeoff_client.TeeoffUnavailableError: + raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.") + + courses: list[OfficialCourseOption] = [] + for c in facility.get("courses", []): + if len(c.get("holes") or []) != 18: + continue + tees: list[TeeOption] = [] + for t in c.get("tees") or []: + genders: list[str] = [] + if t.get("cr_men") is not None and t.get("slope_men") is not None: + genders.append("m") + if t.get("cr_women") is not None and t.get("slope_women") is not None: + genders.append("f") + if genders: + tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders)) + courses.append( + OfficialCourseOption( + teeoff_course_id=c["id"], + name=c["name"], + is_main_course=bool(c.get("is_main_course")), + tees=tees, + ) + ) + return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses) + + # --------------------------------------------------------------------------- # Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker) # --------------------------------------------------------------------------- @@ -512,6 +629,51 @@ async def remove_guest_participant( # Hull-for-hull-registrering # --------------------------------------------------------------------------- +class RoundHoleOut(BaseModel): + hole_number: int + par: int + stroke_index: int + played: bool + score: int | None + putts: int | None + club_off_tee: str | None + tee_shot_result: str | None + approach_result: str | None + chip_count: int | None + bunker_shot_count: int | None + penalty_strokes: int | None + first_putt_distance_m: float | None + + +@router.get( + "/rounds/{round_id}/participants/{participant_id}/holes", + response_model=list[RoundHoleOut], +) +async def list_holes( + round_id: str, + participant_id: str, + user: CurrentUser = Depends(get_current_user), +) -> list[RoundHoleOut]: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + owner_check = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", + participant_id, round_id, + ) + if owner_check is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + rows = await conn.fetch( + """ + SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee, + tee_shot_result, approach_result, chip_count, bunker_shot_count, + penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m + FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number + """, + participant_id, + ) + return [RoundHoleOut(**dict(r)) for r in rows] + + class HoleUpdate(BaseModel): played: bool = True score: int | None = Field(default=None, ge=1, le=20) @@ -525,14 +687,17 @@ class HoleUpdate(BaseModel): first_putt_distance_m: float | None = Field(default=None, ge=0) -@router.patch("/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}") +@router.patch( + "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}", + response_model=RoundHoleOut, +) async def update_hole( round_id: str, participant_id: str, hole_number: int, body: HoleUpdate, user: CurrentUser = Depends(get_current_user), -) -> dict: +) -> RoundHoleOut: async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) owner_check = await conn.fetchval( @@ -550,7 +715,9 @@ async def update_hole( tee_shot_result = $7, approach_result = $8, chip_count = $9, bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12 WHERE round_participant_id = $1 AND hole_number = $2 - RETURNING id + RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee, + tee_shot_result, approach_result, chip_count, bunker_shot_count, + penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m """, participant_id, hole_number, body.played, body.score, body.putts, body.club_off_tee, @@ -559,7 +726,7 @@ async def update_hole( ) if row is None: raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") - return {"ok": True} + return RoundHoleOut(**dict(row)) # --------------------------------------------------------------------------- diff --git a/frontend/app/rounds/[id]/page.tsx b/frontend/app/rounds/[id]/page.tsx new file mode 100644 index 0000000..bf9ef85 --- /dev/null +++ b/frontend/app/rounds/[id]/page.tsx @@ -0,0 +1,6 @@ +import { RoundDetail } from "@/components/round-detail" + +export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + return +} diff --git a/frontend/app/rounds/new/page.tsx b/frontend/app/rounds/new/page.tsx new file mode 100644 index 0000000..8c8dccf --- /dev/null +++ b/frontend/app/rounds/new/page.tsx @@ -0,0 +1,5 @@ +import { NewRound } from "@/components/round-new" + +export default function NewRoundPage() { + return +} diff --git a/frontend/app/rounds/page.tsx b/frontend/app/rounds/page.tsx new file mode 100644 index 0000000..9d7ba8b --- /dev/null +++ b/frontend/app/rounds/page.tsx @@ -0,0 +1,5 @@ +import { PersonalRounds } from "@/components/personal-rounds" + +export default function RoundsPage() { + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index b98eb85..4229c99 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -10,6 +10,7 @@ import { ChevronRight, ChevronsUpDown, ClipboardList, + Flag, LogOut, MessageCircle, Plus, @@ -231,6 +232,13 @@ export function Dashboard() {
+ +