teecup/app/routers/rounds.py

1096 lines
46 KiB
Python
Raw Normal View History

"""
Frittstående rundeføring med detaljert statistikk (ADR-033).
Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS
`round`/`round_participant`/`round_hole`/`personal_course*` (Beslutning A).
Samme mønster som personlig profil/HCP-historikk/sekundær e-post: kun
`plain_connection()`, autorisasjon håndheves eksplisitt her med
`WHERE owner_user_id = $1` (eller via round_id -> owner-kjeden).
Banedata (Beslutning C): offisielle teeoff-baner slås opp LIVE ved hver
runde-opprettelse/deltaker-tilføyelse (ingen lokal kopi) via samme
`teeoff_client` som den org-scopede import-flyten i courses.py bruker --
men her INGEN `course`/`hole`/`tee`-rader skrives, kun et navn-snapshot
selve runden og et rating-snapshot per deltaker.
v1-avgrensning (eksplisitt notert i ADR-033, ikke løst her): en deltaker
med `user_id` satt får IKKE egen tilgang til runden -- derfor støtter
DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.
HCP-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført,
tellende resultat lagres kun som `round_participant.score_differential`.
"""
from __future__ import annotations
import math
from datetime import date, datetime
from typing import Literal
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from .. import teeoff_client
from ..auth import CurrentUser, get_current_user
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
adjusted_gross_score,
allocate_strokes_by_index,
course_handicap,
round_counts_for_handicap,
score_differential,
)
router = APIRouter(tags=["rounds"])
# ---------------------------------------------------------------------------
# Global banekatalog for egendefinerte (ikke-teeoff) baner
# ---------------------------------------------------------------------------
class PersonalCourseHoleIn(BaseModel):
hole_number: int = Field(ge=1, le=18)
par: int = Field(ge=3, le=6)
stroke_index: int = Field(ge=1, le=18)
class PersonalCourseTeeRatingIn(BaseModel):
gender: Literal["m", "f"]
course_rating: float
slope_rating: int = Field(ge=55, le=155)
par: int
class PersonalCourseTeeIn(BaseModel):
name: str
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
class PersonalCourseCreate(BaseModel):
name: str
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
class PersonalCourseOut(BaseModel):
id: str
name: str
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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 = "",
user: CurrentUser = Depends(get_current_user),
) -> list[PersonalCourseOut]:
async with plain_connection() as conn:
rows = await conn.fetch(
"SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20",
f"%{q.strip()}%",
)
return [PersonalCourseOut(**dict(r)) for r in rows]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
numbers = sorted(h.hole_number for h in body.holes)
indexes = sorted(h.stroke_index for h in body.holes)
if numbers != list(range(1, 19)) or indexes != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
async with plain_connection() as conn, translate_db_errors():
async with conn.transaction():
course_row = await conn.fetchrow(
"INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name",
body.name.strip(),
user.user_id,
)
for h in body.holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
course_row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"],
t.name.strip(),
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return PersonalCourseOut(**dict(course_row))
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
# ---------------------------------------------------------------------------
# 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
]
class NearbyFacility(OfficialFacility):
distance_km: float
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
r = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lng2 - lng1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
# MERK: MÅ registreres FØR /rounds/official-search/{slug} under, ellers
# tolker FastAPI "nearby" som en ugyldig slug-verdi til den ruten (samme
# lærdom som ADR-020s "by-code" måtte registreres før {tournament_id}).
@router.get("/rounds/official-search/nearby", response_model=list[NearbyFacility])
async def nearby_official_courses_for_round(
lat: float = Query(...),
lng: float = Query(...),
limit: int = Query(default=5, ge=1, le=20),
user: CurrentUser = Depends(get_current_user),
) -> list[NearbyFacility]:
try:
facilities = await teeoff_client.search_facilities("")
except teeoff_client.TeeoffUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
with_coords = [f for f in facilities if f.get("lat") is not None and f.get("lng") is not None]
scored = sorted(with_coords, key=lambda f: _haversine_km(lat, lng, f["lat"], f["lng"]))
return [
NearbyFacility(
slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"),
distance_km=round(_haversine_km(lat, lng, f["lat"], f["lng"]), 1),
)
for f in scored[:limit]
]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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)
# ---------------------------------------------------------------------------
#
# Returnerer (par_per_hole, stroke_index_per_hole, tee_names) og en
# funksjon for å slå opp (course_rating, slope_rating, par) for en gitt
# tee+kjønn -- felles for BÅDE teeoff (live) og personal_course (lagret).
class _ResolvedCourse:
def __init__(self, holes: list[tuple[int, int, int]], course_name: str):
# holes: liste av (hole_number, par, stroke_index), sortert 1..18
self.holes = holes
self.course_name = course_name
self._ratings: dict[tuple[str, str], tuple[float, int, int]] = {}
def add_rating(self, tee_name: str, gender: str, course_rating: float, slope_rating: int, par: int) -> None:
self._ratings[(tee_name, gender)] = (course_rating, slope_rating, par)
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
return self._ratings.get((tee_name, gender))
async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
try:
facility = await teeoff_client.get_facility(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å.")
course_data = next((c for c in facility.get("courses", []) if c.get("id") == teeoff_course_id), None)
if course_data is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.")
raw_holes = course_data.get("holes") or []
if len(raw_holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå.")
for h in raw_holes:
if h.get("par") is None or h.get("hcp_index") is None:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.")
course_name = f"{facility.get('name')} {course_data.get('name') or 'Bane'}"
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes),
course_name=course_name,
)
for t in course_data.get("tees") or []:
name = t.get("name") or "Tee"
if t.get("cr_men") is not None and t.get("slope_men") is not None:
resolved.add_rating(name, "m", float(t["cr_men"]), int(t["slope_men"]), sum(h[1] for h in resolved.holes))
if t.get("cr_women") is not None and t.get("slope_women") is not None:
resolved.add_rating(name, "f", float(t["cr_women"]), int(t["slope_women"]), sum(h[1] for h in resolved.holes))
return resolved
async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
async with plain_connection() as conn:
course_row = await conn.fetchrow("SELECT 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.")
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1",
personal_course_id,
)
tee_rows = await conn.fetch(
"""
SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
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
""",
personal_course_id,
)
resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["stroke_index"]) for h in hole_rows),
course_name=course_row["name"],
)
for t in tee_rows:
resolved.add_rating(t["tee_name"], t["gender"], float(t["course_rating"]), t["slope_rating"], t["par"])
return resolved
# ---------------------------------------------------------------------------
# Runder
# ---------------------------------------------------------------------------
# Statistikknivå per deltaker (2026-07-24): kun slag er strengt tatt
# nødvendig for resultat/HCP -- putter og "flere detaljer" er valgfritt,
# av som default. GIR krever putts og vises derfor ikke i strokes_only.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"]
teeoff_facility_slug: str | None = None
teeoff_course_id: int | None = None
personal_course_id: str | None = None
tee_name: str
played_at: date
start_hole: int = Field(default=1, ge=1, le=18)
holes_planned: Literal[9, 18] = 18
stat_level: StatLevel = "strokes_only"
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
# tidsbruk (completed_at - started_at) når runden fullføres.
started_at: datetime | None = None
class RoundParticipantOut(BaseModel):
id: str
user_id: str | None
guest_name: str | None
is_owner: bool
gender: str
handicap_index_snapshot: float | None
course_handicap_snapshot: int | None
counts_for_handicap: bool
score_differential: float | None
stat_level: StatLevel
class RoundOut(BaseModel):
id: str
course_source: str
course_name_snapshot: str
tee_name_snapshot: str
played_at: date
start_hole: int
holes_planned: int
started_at: str | None
completed_at: str | None
participants: list[RoundParticipantOut]
# Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) --
# brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i
# det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker
# 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er
# registrert.
owner_holes_played: int
owner_total_score: int | None
owner_score_to_par: int | None
async def _load_round_out(conn, round_id: str) -> RoundOut:
round_row = await conn.fetchrow(
"""
SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, started_at, completed_at
FROM round WHERE id = $1
""",
round_id,
)
participant_rows = await conn.fetch(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
""",
round_id,
)
owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None)
owner_agg = await conn.fetchrow(
"""
SELECT COUNT(*) FILTER (WHERE played) AS played_count,
COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
FROM round_hole WHERE round_participant_id = $1
""",
owner_id,
)
owner_holes_played = owner_agg["played_count"] if owner_agg else 0
owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None
owner_score_to_par = (
owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None
)
return RoundOut(
id=round_row["id"],
course_source=round_row["course_source"],
course_name_snapshot=round_row["course_name_snapshot"],
tee_name_snapshot=round_row["tee_name_snapshot"],
played_at=round_row["played_at"],
start_hole=round_row["start_hole"],
holes_planned=round_row["holes_planned"],
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
owner_holes_played=owner_holes_played,
owner_total_score=owner_total_score,
owner_score_to_par=owner_score_to_par,
)
async def _create_participant(
conn,
round_id: str,
resolved: _ResolvedCourse,
tee_name: str,
*,
user_id: str | None,
guest_name: str | None,
is_owner: bool,
gender: str,
handicap_index: float | None,
stat_level: StatLevel = "strokes_only",
) -> str:
rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None
course_handicap_snapshot = None
if rating is not None:
course_rating, slope_rating, tee_par = rating
if handicap_index is not None:
course_handicap_snapshot = course_handicap(handicap_index, slope_rating, course_rating, tee_par)
participant_row = await conn.fetchrow(
"""
INSERT INTO round_participant
(round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id
""",
round_id,
user_id,
guest_name,
is_owner,
gender,
handicap_index,
course_rating,
slope_rating,
tee_par,
course_handicap_snapshot,
stat_level,
)
participant_id = participant_row["id"]
for hole_number, par, stroke_index in resolved.holes:
await conn.execute(
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
participant_id,
hole_number,
par,
stroke_index,
)
return participant_id
@router.post("/rounds", response_model=RoundOut, status_code=201)
async def create_round(
body: RoundCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
if body.course_source == "teeoff":
if not body.teeoff_facility_slug or not body.teeoff_course_id:
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
else:
if not body.personal_course_id:
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id)
async with plain_connection() as conn:
owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
user.user_id,
)
if owner_row is None or owner_row["gender"] is None:
raise app_error(400, "VALIDATION_FAILED", "Fullfør profilen din (kjønn/HCP) før du registrerer en runde.")
if resolved.rating_for(body.tee_name, owner_row["gender"]) is None:
raise app_error(
400, "VALIDATION_FAILED",
"Valgt utslag har ingen rating for ditt registrerte kjønn på denne banen.",
)
async with conn.transaction(), translate_db_errors():
round_row = await conn.fetchrow(
"""
INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id
""",
user.user_id,
body.course_source,
body.teeoff_facility_slug,
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
body.personal_course_id,
resolved.course_name,
body.tee_name,
body.played_at,
body.start_hole,
body.holes_planned,
body.started_at,
)
round_id = round_row["id"]
await _create_participant(
conn, round_id, resolved, body.tee_name,
user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level,
)
return await _load_round_out(conn, round_id)
@router.get("/rounds", response_model=list[RoundOut])
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
async with plain_connection() as conn:
ids = await conn.fetch(
"SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC",
user.user_id,
)
return [await _load_round_out(conn, r["id"]) for r in ids]
async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id)
if row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if row["owner_user_id"] != user_id:
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
@router.get("/rounds/{round_id}", response_model=RoundOut)
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
return await _load_round_out(conn, round_id)
class RoundUpdate(BaseModel):
# Bane-bytte er ATOMISK -- enten oppgis course_source+tee_name (pluss de
# kildespesifikke feltene) sammen, eller ingen av dem. holes_planned/
# start_hole kan endres uavhengig, i samme kall eller alene -- disse
# rører aldri round_hole, kun visnings-/navigasjonsmetadata.
course_source: Literal["teeoff", "custom"] | None = None
teeoff_facility_slug: str | None = None
teeoff_course_id: int | None = None
personal_course_id: str | None = None
tee_name: str | None = None
holes_planned: Literal[9, 18] | None = None
start_hole: int | None = Field(default=None, ge=1, le=18)
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
# en runde som allerede er fullført (se validering under) -- denne
# PATCH-en fullfører aldri runden selv, kun korrigerer et tidspunkt som
# /complete allerede har satt.
started_at: datetime | None = None
completed_at: datetime | None = None
@router.patch("/rounds/{round_id}", response_model=RoundOut)
async def update_round(
round_id: str,
body: RoundUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
"""
Retter opp feil bane/utslag/antall hull/starthull ETTER at runden er
opprettet (2026-07-24) -- f.eks. hvis man oppdager underveis at feil
bane ble valgt, eller vil justere utslags-/fullført-tidspunktet i
etterkant. Endrer ALDRI allerede registrerte slag/putter/etc. i
round_hole -- kun rating-grunnlaget (par/stroke-index-snapshot +
course/slope-rating) hullene regnes ut fra. Bane-bytte og
holes_planned er bevisst avvist etter at runden er fullført
(differensialen er da allerede beregnet fra det gamle grunnlaget, og
en re-beregning etter fullføring er utenfor omfang her -- ulikt
tilsvarende bane-bytte for turnering-økter, som bevisst TILLATER dette
selv etter avgjørelse). start_hole/started_at/completed_at er derimot
ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
uansett fullført-status.
"""
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
is_completed = round_row["completed_at"] is not None
if is_completed and (body.course_source is not None or body.holes_planned is not None):
raise app_error(
409, "ALREADY_COMPLETED",
"Runden er allerede fullført -- kan ikke endre bane eller antall hull i etterkant.",
)
if body.completed_at is not None and not is_completed:
raise app_error(
400, "VALIDATION_FAILED",
"Runden må fullføres via \"Fullfør runde\" først -- deretter kan tidspunktet justeres.",
)
effective_started_at = body.started_at if body.started_at is not None else round_row["started_at"]
effective_completed_at = body.completed_at if body.completed_at is not None else round_row["completed_at"]
if (
effective_started_at is not None
and effective_completed_at is not None
and effective_completed_at <= effective_started_at
):
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
resolved: _ResolvedCourse | None = None
participants = None
if body.course_source is not None:
if not body.tee_name:
raise app_error(400, "VALIDATION_FAILED", "tee_name er påkrevd ved bane-bytte.")
if body.course_source == "teeoff":
if not body.teeoff_facility_slug or not body.teeoff_course_id:
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
else:
if not body.personal_course_id:
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
resolved = await _resolve_personal_course(body.personal_course_id)
participants = await conn.fetch(
"""
SELECT id::text AS id, gender, handicap_index_snapshot::float AS handicap_index_snapshot
FROM round_participant WHERE round_id = $1
""",
round_id,
)
# Valider FOR ALLE deltakere FØR noe skrives -- hele bane-byttet
# avvises tydelig hvis ÉN eneste deltaker ville mistet HCP-
# sporing, ingen delvis anvendt endring.
for p in participants:
if resolved.rating_for(body.tee_name, p["gender"]) is None:
raise app_error(
400, "VALIDATION_FAILED",
"Den nye banen/utslaget mangler rating for én eller flere av deltakernes kjønn -- ingenting er endret.",
)
async with conn.transaction():
if body.holes_planned is not None:
await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned)
if body.start_hole is not None:
await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole)
if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None:
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
if resolved is not None and participants is not None:
await conn.execute(
"""
UPDATE round SET course_source = $2, teeoff_facility_slug = $3, teeoff_course_id = $4,
personal_course_id = $5, course_name_snapshot = $6, tee_name_snapshot = $7
WHERE id = $1
""",
round_id,
body.course_source,
body.teeoff_facility_slug,
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
body.personal_course_id,
resolved.course_name,
body.tee_name,
)
par_by_hole = {h[0]: h[1] for h in resolved.holes}
index_by_hole = {h[0]: h[2] for h in resolved.holes}
for p in participants:
course_rating, slope_rating, tee_par = resolved.rating_for(body.tee_name, p["gender"])
course_handicap_snapshot = None
if p["handicap_index_snapshot"] is not None:
course_handicap_snapshot = course_handicap(
p["handicap_index_snapshot"], slope_rating, course_rating, tee_par
)
await conn.execute(
"""
UPDATE round_participant SET course_rating_snapshot = $2, slope_rating_snapshot = $3,
tee_par_snapshot = $4, course_handicap_snapshot = $5
WHERE id = $1
""",
p["id"], course_rating, slope_rating, tee_par, course_handicap_snapshot,
)
for hole_number in range(1, 19):
await conn.execute(
"""
UPDATE round_hole SET par = $3, stroke_index = $4
WHERE round_participant_id = $1 AND hole_number = $2
""",
p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number],
)
return await _load_round_out(conn, round_id)
@router.delete("/rounds/{round_id}", status_code=204)
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
await conn.execute("DELETE FROM round WHERE id = $1", round_id)
# ---------------------------------------------------------------------------
# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1
# ---------------------------------------------------------------------------
class GuestParticipantCreate(BaseModel):
guest_name: str = Field(min_length=1, max_length=100)
gender: Literal["m", "f", "x"]
handicap_index: float | None = Field(default=None, ge=-10, le=54)
stat_level: StatLevel = "strokes_only"
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_guest_participant(
round_id: str,
body: GuestParticipantCreate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1",
round_id,
)
if round_row["course_source"] == "teeoff":
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
else:
resolved = await _resolve_personal_course(round_row["personal_course_id"])
if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None:
raise app_error(
400, "VALIDATION_FAILED",
"Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
)
async with conn.transaction(), translate_db_errors():
participant_id = await _create_participant(
conn, round_id, resolved, round_row["tee_name_snapshot"],
user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
gender=body.gender, handicap_index=body.handicap_index,
stat_level=body.stat_level,
)
row = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE id = $1
""",
participant_id,
)
return RoundParticipantOut(**dict(row))
class ParticipantUpdate(BaseModel):
stat_level: StatLevel
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
async def update_participant(
round_id: str,
participant_id: str,
body: ParticipantUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"""
UPDATE round_participant SET stat_level = $3
WHERE id = $1 AND round_id = $2
RETURNING id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
""",
participant_id, round_id, body.stat_level,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
return RoundParticipantOut(**dict(row))
@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant(
round_id: str,
participant_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"SELECT is_owner FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
if row["is_owner"]:
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id)
# ---------------------------------------------------------------------------
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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_bucket: str | None
anyway_strokes: int | None
# Slag mottatt på dette hullet, utledet fra course_handicap_snapshot
# (samme allokeringsalgoritme som resten av appen) -- kun til visning
# av netto-score i en oversiktstabell, aldri lagret. None hvis
# deltakeren ikke har en beregnet course handicap (f.eks. gjest uten
# HCP).
strokes_received: int | None
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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)
participant_row = await conn.fetchrow(
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
participant_id, round_id,
)
if participant_row is None:
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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_bucket, anyway_strokes
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
participant_id,
)
strokes_received_by_hole: dict[int, int] | None = None
if participant_row["course_handicap_snapshot"] is not None:
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in rows]
)
strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}
return [
RoundHoleOut(
**dict(r),
strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
)
for r in rows
]
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
class HoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
putts: int | None = Field(default=None, ge=0, le=10)
club_off_tee: str | None = Field(default=None, max_length=50)
tee_shot_result: Literal["fairway", "left", "right"] | None = None
approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
chip_count: int | None = Field(default=None, ge=0)
bunker_shot_count: int | None = Field(default=None, ge=0)
penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
anyway_strokes: int | None = Field(default=None, ge=0)
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
@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),
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
) -> RoundHoleOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
participant_row = await conn.fetchrow(
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
participant_id, round_id,
)
if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
async with translate_db_errors():
row = await conn.fetchrow(
"""
UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6,
tee_shot_result = $7, approach_result = $8, chip_count = $9,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12,
anyway_strokes = $13
WHERE round_participant_id = $1 AND hole_number = $2
Update Todos Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py Build /rounds list page + component Build /rounds/new creation flow (course search/create, tee, date, holes) Build /rounds/[id] detail page (participants, hole-by-hole stats, complete) Link new feature from dashboard Scratch-verify backend additions + typecheck production build Deploy live after user confirmation Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]). What's new, in summary: Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched. Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card. One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
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_bucket, anyway_strokes
""",
participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
body.anyway_strokes,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
# strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge
# (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene
# hullet -- må derfor hente alle 18 sin stroke_index for å plassere
# riktig antall mottatte slag på nøyaktig dette hullet.
strokes_received = None
if participant_row["course_handicap_snapshot"] is not None:
all_indexes = await conn.fetch(
"SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
participant_id,
)
allocation = allocate_strokes_by_index(
participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes]
)
by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
strokes_received = by_hole[hole_number]
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
# ---------------------------------------------------------------------------
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
# ---------------------------------------------------------------------------
@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)
participants = await conn.fetch(
"""
SELECT id::text AS id, handicap_index_snapshot::float AS handicap_index_snapshot,
course_rating_snapshot::float AS course_rating_snapshot,
slope_rating_snapshot, tee_par_snapshot
FROM round_participant WHERE round_id = $1
""",
round_id,
)
for p in participants:
holes = await conn.fetch(
"""
SELECT hole_number, par, stroke_index, played, score
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
p["id"],
)
played_count = sum(1 for h in holes if h["played"])
counts = round_counts_for_handicap(played_count, round_row["holes_planned"])
differential = None
if counts and p["handicap_index_snapshot"] is not None and p["course_rating_snapshot"] is not None:
pars = [h["par"] for h in holes]
strokes_received = allocate_strokes_by_index(
_course_handicap_from_row(p),
[h["stroke_index"] for h in holes],
)
scores = [h["score"] if h["played"] else None for h in holes]
ags = adjusted_gross_score(scores, pars, strokes_received)
differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"])
await conn.execute(
"UPDATE round_participant SET counts_for_handicap = $2, score_differential = $3 WHERE id = $1",
p["id"], counts, differential,
)
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
return await _load_round_out(conn, round_id)
def _course_handicap_from_row(p) -> int:
return course_handicap(
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
)