Update Todos
Diffe zip 7 mot live-treet Integrere session-scorecard.tsx mot ekte API Lenke fra blind draw/program til scorekort-siden Ekte typesjekket frontend-build Scratch/manuell verifisering av hele skrive-flyten Rulle ut live Oppdatere .md-filer + slette zip 7 Scorekort-skjermen er live. Oppsummert: Ett hull i fokus om gangen, store steppere for slag-modus, tre-valgs vinnerknapper for hullresultat-modus, hull-navigasjon med registrert-markering, kollapsbar full oversikt, og et feiret «avgjort»-banner som låser alt read-only Lenket inn fra blind draw sin avslørte visning Ingen nye backend-hull denne runden — forrige rundes forarbeid dekket alt Verifisert med en test som speiler frontend-ens egen last-sekvens nøyaktig, test_isolation.sql 12/12, ekte typesjekket build .md-filene oppdatert, zip 7 slettet
This commit is contained in:
parent
02247a1e5f
commit
21196c9cfd
8 changed files with 967 additions and 10 deletions
|
|
@ -257,7 +257,11 @@
|
||||||
"Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)",
|
"Bash(python3 -m py_compile /opt/teecup/app/routers/matches.py)",
|
||||||
"Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)",
|
"Bash(python3 -m py_compile /opt/teecup/app/blind_draw.py /opt/teecup/app/routers/matches.py)",
|
||||||
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/del_cookies.txt /tmp/captvis_cookies.txt)",
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/del_cookies.txt /tmp/captvis_cookies.txt)",
|
||||||
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)"
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/hcp_cookies.txt)",
|
||||||
|
"Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py /opt/teecup/app/routers/scoring.py)",
|
||||||
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/score_cookies.txt)",
|
||||||
|
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-7)",
|
||||||
|
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/sc_cookies.txt)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -880,11 +880,53 @@ Ferdig og verifisert:
|
||||||
**Rullet ut live**, bruker bekreftet eksplisitt: begge containere
|
**Rullet ut live**, bruker bekreftet eksplisitt: begge containere
|
||||||
bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket.
|
bygget+redeployet (ingen migrasjon), `teeoff.no` upåvirket.
|
||||||
|
|
||||||
|
- **Backend-forarbeid for scorekort-skjermen, LIVE (2026-07-18):** to hull
|
||||||
|
funnet ved gjennomlesing av `scoring.py` FØR V0-prompten ble skrevet,
|
||||||
|
samme føre-var-mønster som resten av økten.
|
||||||
|
1. **Hull-/stroke-index-data for egendefinerte baner** (bevisst utsatt fra
|
||||||
|
tee-rundens status-notat) — `hole`-tabellen har ALDRI hatt noen vei inn
|
||||||
|
for custom-baner, som blokkerte `stroke`-scoringsmodus helt (`hole_
|
||||||
|
result`-modus trenger den ikke). Ny `GET/POST /orgs/{id}/courses/{id}/
|
||||||
|
holes` i `app/routers/courses.py` — engangs alle-18-på-en-gang
|
||||||
|
(avviser feil antall, avviser hvis banen allerede har hull, avviser på
|
||||||
|
offisielle baner som får hullene sine fra teeoff-import).
|
||||||
|
2. **`GET scorecard` viste kun UTLEDET vinn/tap/delt per hull, aldri de
|
||||||
|
faktiske tallene som var registrert** — umulig å bygge en skjerm som
|
||||||
|
viser gjeldende tilstand ved gjenlasting. Utvidet `Scorecard`-modellen
|
||||||
|
i `app/routers/scoring.py` med `stroke_entries`/`hole_result_entries`
|
||||||
|
(rå `hole_score`/`match_hole_result`-rader, nøyaktig ett av de to fylt
|
||||||
|
ut avhengig av øktens scoring_mode).
|
||||||
|
**Verifisert grundig i scratch:** hull-validering (feil antall avvist,
|
||||||
|
gjentatt oppsett avvist), og en FULL `stroke`-modus scoringsrunde med
|
||||||
|
ekte handicap-justert nettoberegning på en egendefinert bane for FØRSTE
|
||||||
|
gang noensinne (ulikt slagtall — 4 mot 5 — ga korrekt "halved" etter
|
||||||
|
handicap-utjevning), scorecard sin nye `stroke_entries` bekreftet å
|
||||||
|
returnere nøyaktig det som ble registrert. `test_isolation.sql` 12/12.
|
||||||
|
**Rullet ut live**, ingen migrasjon, kun `teecup_api` redeployet,
|
||||||
|
`teeoff.no` upåvirket.
|
||||||
|
|
||||||
|
- **Scorekort-skjermen LIVE (2026-07-18):** åttende V0-skjerm,
|
||||||
|
`components/session-scorecard.tsx`, ny rute `/tournaments/[id]/sessions/
|
||||||
|
[sessionId]/matches/[matchId]`. Ett hull i fokus om gangen (banebruk,
|
||||||
|
ikke et regneark) — store slag-steppere for `stroke`-modus, tre-valgs
|
||||||
|
vinner-knapper for `hole_result`-modus, hull-chip-navigasjon, kollapsbar
|
||||||
|
full oversikt, feiret "avgjort"-banner som låser alt til read-only.
|
||||||
|
Blind draw sin avslørte visning lenker nå til hver match sitt scorekort.
|
||||||
|
**Ingen nye backend-hull denne runden** — forrige rundes forarbeid
|
||||||
|
(hull-endepunkter + `stroke_entries`/`hole_result_entries` i scorecard)
|
||||||
|
dekket akkurat det skjermen trengte.
|
||||||
|
**Verifisert grundig i scratch, inkludert et fullt oppsett som speiler
|
||||||
|
NØYAKTIG frontend-ens egen last-sekvens** (sessions→teams→matches→
|
||||||
|
holes→scorecard, deretter en ekte `POST hole-scores` for begge spillere
|
||||||
|
på hull 1 og en refetch): status_text/derivert resultat oppdaterte seg
|
||||||
|
korrekt ("1 UP (A)", hull 1 → "a"), alle feltnavn stemte eksakt med
|
||||||
|
TypeScript-typene uten justering. `test_isolation.sql` 12/12, ekte
|
||||||
|
typesjekket build.
|
||||||
|
**Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. Scorekort-skjermen (neste i "bygg i rekkefølgen ting brukes") — her må
|
1. Leaderboard-skjermen (siste i "bygg i rekkefølgen ting brukes"-serien
|
||||||
også hull-/stroke-index-data for egendefinerte baner løses (bevisst
|
for match-play-flyten). Samme mønster:
|
||||||
utsatt fra tee-rundens tidligere status-notat). Deretter leaderboard.
|
|
||||||
Samme mønster:
|
|
||||||
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver
|
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
|
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
|
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,18 @@
|
||||||
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
|
- **Status:** ✅ skjema (migrasjon 003) + API bygget og verifisert
|
||||||
(`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
|
(`app/routers/scoring.py`: `hole-scores` for `stroke`-modus,
|
||||||
`hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
|
`hole-results` for `hole_result`-modus, begge mater samme `compute_match_state`).
|
||||||
|
- **Nytt 2026-07-18, ✅ BYGGET (forarbeid før scorekort-skjermen):**
|
||||||
|
`GET/POST /orgs/{id}/courses/{id}/holes` løser hull-/stroke-index-hullet
|
||||||
|
fra tee-runden (kun `stroke`-modus trengte det). `GET scorecard` utvidet
|
||||||
|
med `stroke_entries`/`hole_result_entries` (rå registrerte tall, ikke
|
||||||
|
bare utledet vinn/tap/delt) — uten dette kunne ikke en gjenlastet
|
||||||
|
scorekort-side vise gjeldende tilstand. Verifisert med en full
|
||||||
|
`stroke`-runde med ekte handicap-justering på en egendefinert bane.
|
||||||
|
- **Frontend LIVE 2026-07-18:** `/tournaments/[id]/sessions/[sessionId]/
|
||||||
|
matches/[matchId]`, `components/session-scorecard.tsx`. Ett hull om
|
||||||
|
gangen med store steppere/knapper (banebruk i sollys, ikke et regneark
|
||||||
|
— matcher UX-visjonen lenger ned i denne fila). Lenket fra blind draw
|
||||||
|
sin avslørte visning. Ingen nye backend-hull denne runden.
|
||||||
|
|
||||||
### Bøtekasse (Kangaroo Court)
|
### Bøtekasse (Kangaroo Court)
|
||||||
- **Status:** 📋 planlagt
|
- **Status:** 📋 planlagt
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,89 @@ async def create_tee(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hull (ADR-008-relatert: stroke_index kreves for slagfordeling i --
|
||||||
|
# scoring-fasen -- se app/routers/scoring.py sin bruk av `hole`-tabellen) -
|
||||||
|
#
|
||||||
|
# Egendefinerte baner har ALDRI hatt noen vei til hull-data, uavhengig av
|
||||||
|
# ADR-019 (samme klasse hull som tee-mangelen -- flagget FØR scorekort-
|
||||||
|
# skjermen ble bygget, ikke oppdaget for sent). Kun `stroke`-scoringsmodus
|
||||||
|
# trenger dette; `hole_result`-modus fungerer allerede uten. Engangs
|
||||||
|
# oppsett: enten alle 18 hull settes i ÉN innsending, eller ingen -- unngår
|
||||||
|
# kompleksiteten med delvise/partielle hull-sett.
|
||||||
|
|
||||||
|
|
||||||
|
class HoleCreate(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 Hole(BaseModel):
|
||||||
|
hole_number: int
|
||||||
|
par: int
|
||||||
|
stroke_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole])
|
||||||
|
async def list_holes(
|
||||||
|
course_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[Hole]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
return [Hole(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orgs/{organization_id}/courses/{course_id}/holes", response_model=list[Hole], status_code=201)
|
||||||
|
async def create_holes(
|
||||||
|
course_id: str,
|
||||||
|
body: list[HoleCreate],
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[Hole]:
|
||||||
|
if len(body) != 18:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull i én innsending.")
|
||||||
|
numbers = sorted(h.hole_number for h in body)
|
||||||
|
if numbers != list(range(1, 19)):
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Hullnumrene må dekke nøyaktig 1-18, hver én gang.")
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
source = await conn.fetchval("SELECT source FROM course WHERE id = $1", course_id)
|
||||||
|
if source is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
|
||||||
|
if source == "official":
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Offisielle baner får hullene sine fra teeoff -- kan ikke legges til manuelt.",
|
||||||
|
)
|
||||||
|
existing = await conn.fetchval("SELECT count(*) FROM hole WHERE course_id = $1", course_id)
|
||||||
|
if existing > 0:
|
||||||
|
raise app_error(
|
||||||
|
409, "DUPLICATE", "Banen har allerede registrert hull -- kan ikke settes opp på nytt."
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for h in body:
|
||||||
|
rows.append(
|
||||||
|
await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING hole_number, par, stroke_index
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
course_id,
|
||||||
|
h.hole_number,
|
||||||
|
h.par,
|
||||||
|
h.stroke_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
|
||||||
|
|
||||||
|
|
||||||
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------
|
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,12 @@ class Scorecard(BaseModel):
|
||||||
points_side_a: float | None
|
points_side_a: float | None
|
||||||
points_side_b: float | None
|
points_side_b: float | None
|
||||||
holes: list[ScorecardHole]
|
holes: list[ScorecardHole]
|
||||||
|
# Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som
|
||||||
|
# faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet
|
||||||
|
# (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de
|
||||||
|
# to feltene er fylt ut, avhengig av øktens scoring_mode.
|
||||||
|
stroke_entries: list[HoleScoreOut] | None = None
|
||||||
|
hole_result_entries: list[HoleResultOut] | None = None
|
||||||
|
|
||||||
|
|
||||||
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
||||||
|
|
@ -430,11 +436,35 @@ async def get_scorecard(
|
||||||
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
||||||
results = await _compute_hole_results(conn, match_id, match)
|
results = await _compute_hole_results(conn, match_id, match)
|
||||||
|
|
||||||
|
stroke_entries = None
|
||||||
|
hole_result_entries = None
|
||||||
|
if match["scoring_mode"] == "stroke":
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, team_side::text AS team_side,
|
||||||
|
match_participant_id::text AS match_participant_id, gross_strokes
|
||||||
|
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, winning_side::text AS winning_side
|
||||||
|
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = _played_hole_numbers(match["hole_config"])
|
||||||
holes = [
|
holes = [
|
||||||
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
|
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
|
||||||
]
|
]
|
||||||
return Scorecard(
|
return Scorecard(
|
||||||
|
stroke_entries=stroke_entries,
|
||||||
|
hole_result_entries=hole_result_entries,
|
||||||
match_id=match["id"],
|
match_id=match["id"],
|
||||||
status_text=match["status_text"],
|
status_text=match["status_text"],
|
||||||
points_side_a=match["points_side_a"],
|
points_side_a=match["points_side_a"],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { SessionScorecard } from "@/components/session-scorecard"
|
||||||
|
|
||||||
|
export default async function SessionScorecardPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; sessionId: string; matchId: string }>
|
||||||
|
searchParams: Promise<{ org?: string; name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id, sessionId, matchId } = await params
|
||||||
|
const { org, name } = await searchParams
|
||||||
|
const tournamentName = name?.trim() || "Turnering"
|
||||||
|
|
||||||
|
// Samme grunn som de andre turnering-undersidene: API-et krever
|
||||||
|
// organization_id på alle match-/scoring-endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionScorecard
|
||||||
|
organizationId={org}
|
||||||
|
tournamentId={id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
matchId={matchId}
|
||||||
|
tournamentName={tournamentName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -343,7 +343,13 @@ export function SessionBlindDraw({
|
||||||
<StatusBanner teams={teams} lockedTeamIds={session.locked_team_ids} bothLocked={bothLocked} />
|
<StatusBanner teams={teams} lockedTeamIds={session.locked_team_ids} bothLocked={bothLocked} />
|
||||||
|
|
||||||
{bothLocked ? (
|
{bothLocked ? (
|
||||||
<RevealedView teams={teams} matches={sortedMatches} />
|
<RevealedView
|
||||||
|
teams={teams}
|
||||||
|
matches={sortedMatches}
|
||||||
|
scorecardHrefFor={(matchId) =>
|
||||||
|
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
{teams.map((team, teamIndex) => (
|
{teams.map((team, teamIndex) => (
|
||||||
|
|
@ -682,13 +688,22 @@ function AddSlotForm({
|
||||||
|
|
||||||
// --- Revealed view ---------------------------------------------------------
|
// --- Revealed view ---------------------------------------------------------
|
||||||
|
|
||||||
function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches: ApiMatch[] }) {
|
function RevealedView({
|
||||||
|
teams,
|
||||||
|
matches,
|
||||||
|
scorecardHrefFor,
|
||||||
|
}: {
|
||||||
|
teams: [ApiTeam, ApiTeam]
|
||||||
|
matches: ApiMatch[]
|
||||||
|
scorecardHrefFor: (matchId: string) => string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
{matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<div
|
<Link
|
||||||
key={match.id}
|
key={match.id}
|
||||||
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"
|
href={scorecardHrefFor(match.id)}
|
||||||
|
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
|
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
|
||||||
|
|
@ -709,7 +724,7 @@ function RevealedView({ teams, matches }: { teams: [ApiTeam, ApiTeam]; matches:
|
||||||
</div>
|
</div>
|
||||||
<RevealSide team={teams[1]} participants={match.participants.filter((p) => p.team_side === "b")} align="right" />
|
<RevealSide team={teams[1]} participants={match.participants.filter((p) => p.team_side === "b")} align="right" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
736
frontend/components/session-scorecard.tsx
Normal file
736
frontend/components/session-scorecard.tsx
Normal file
|
|
@ -0,0 +1,736 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Trophy,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py)
|
||||||
|
|
||||||
|
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
||||||
|
type ScoringMode = "stroke" | "hole_result"
|
||||||
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||||
|
|
||||||
|
const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"]
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<Format, string> = {
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2)",
|
||||||
|
scramble_4: "Scramble (4)",
|
||||||
|
fourball: "Fourball",
|
||||||
|
singles: "Singel",
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiTeam = { id: string; name: string; color: string | null }
|
||||||
|
|
||||||
|
type ApiSession = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
format: Format
|
||||||
|
hole_config: HoleConfig
|
||||||
|
course_id: string
|
||||||
|
scoring_mode: ScoringMode
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiParticipant = {
|
||||||
|
id: string
|
||||||
|
team_side: "a" | "b"
|
||||||
|
team_roster_id: string
|
||||||
|
player_name: string
|
||||||
|
tee_id: string
|
||||||
|
tee_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiMatch = {
|
||||||
|
id: string
|
||||||
|
sequence: number
|
||||||
|
team_a_id: string
|
||||||
|
team_b_id: string
|
||||||
|
participants: ApiParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiHole = { hole_number: number; par: number; stroke_index: number }
|
||||||
|
|
||||||
|
type ApiHoleScore = {
|
||||||
|
hole_number: number
|
||||||
|
team_side: "a" | "b"
|
||||||
|
match_participant_id: string | null
|
||||||
|
gross_strokes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null }
|
||||||
|
|
||||||
|
type ApiScorecard = {
|
||||||
|
match_id: string
|
||||||
|
status_text: string | null
|
||||||
|
points_side_a: number | null
|
||||||
|
points_side_b: number | null
|
||||||
|
holes: { hole_number: number; result: "a" | "b" | "halved" }[]
|
||||||
|
stroke_entries: ApiHoleScore[] | null
|
||||||
|
hole_result_entries: ApiHoleResult[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhet som scores: én per SPILLER for individuelle formater (singles/
|
||||||
|
// fourball), én per SIDE for delt-ball-formater (foursome/greensome/
|
||||||
|
// scramble) -- se app/routers/scoring.py sin is_individual-sjekk.
|
||||||
|
type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null }
|
||||||
|
|
||||||
|
function playedHoleNumbers(holeConfig: HoleConfig): number[] {
|
||||||
|
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||||
|
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
||||||
|
return Array.from({ length: 18 }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionScorecard({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
sessionId,
|
||||||
|
matchId,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
sessionId: string
|
||||||
|
matchId: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const [session, setSession] = useState<ApiSession | null>(null)
|
||||||
|
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
|
||||||
|
const [match, setMatch] = useState<ApiMatch | null>(null)
|
||||||
|
const [holes, setHoles] = useState<ApiHole[]>([])
|
||||||
|
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [currentHole, setCurrentHole] = useState<number | null>(null)
|
||||||
|
const [showSummary, setShowSummary] = useState(false)
|
||||||
|
|
||||||
|
async function refetchScorecard() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.ok) setScorecard(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [sessionsRes, teamsRes, matchesRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (!sessionsRes.ok || !teamsRes.ok || !matchesRes.ok) throw new Error("load failed")
|
||||||
|
const sessionsData: ApiSession[] = await sessionsRes.json()
|
||||||
|
const foundSession = sessionsData.find((s) => s.id === sessionId)
|
||||||
|
if (!foundSession) throw new Error("session not found")
|
||||||
|
const teamsData: ApiTeam[] = await teamsRes.json()
|
||||||
|
if (teamsData.length !== 2) throw new Error("expected 2 teams")
|
||||||
|
const matchesData: ApiMatch[] = await matchesRes.json()
|
||||||
|
const foundMatch = matchesData.find((m) => m.id === matchId)
|
||||||
|
if (!foundMatch) throw new Error("match not found")
|
||||||
|
|
||||||
|
const [holesRes, scorecardRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/holes`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (!holesRes.ok || !scorecardRes.ok) throw new Error("load failed")
|
||||||
|
const holesData: ApiHole[] = await holesRes.json()
|
||||||
|
const scorecardData: ApiScorecard = await scorecardRes.json()
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
setSession(foundSession)
|
||||||
|
setTeams([teamsData[0], teamsData[1]])
|
||||||
|
setMatch(foundMatch)
|
||||||
|
setHoles(holesData)
|
||||||
|
setScorecard(scorecardData)
|
||||||
|
const played = playedHoleNumbers(foundSession.hole_config)
|
||||||
|
const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1]
|
||||||
|
setCurrentHole(nextUnregistered)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.")
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, tournamentId, sessionId, matchId])
|
||||||
|
|
||||||
|
const units: Unit[] = useMemo(() => {
|
||||||
|
if (!session || !match || !teams) return []
|
||||||
|
const isIndividual = INDIVIDUAL_FORMATS.includes(session.format)
|
||||||
|
if (isIndividual) {
|
||||||
|
return match.participants.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
side: p.team_side,
|
||||||
|
label: p.player_name,
|
||||||
|
matchParticipantId: p.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ id: "a", side: "a", label: teams[0].name, matchParticipantId: null },
|
||||||
|
{ id: "b", side: "b", label: teams[1].name, matchParticipantId: null },
|
||||||
|
]
|
||||||
|
}, [session, match, teams])
|
||||||
|
|
||||||
|
const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null
|
||||||
|
|
||||||
|
async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
team_side: unit.side,
|
||||||
|
match_participant_id: unit.matchParticipantId,
|
||||||
|
hole_number: holeNumber,
|
||||||
|
gross_strokes: grossStrokes,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`submit stroke: ${res.status}`)
|
||||||
|
await refetchScorecard()
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`submit result: ${res.status}`)
|
||||||
|
await refetchScorecard()
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
||||||
|
if (loading || !session || !teams || !match || !scorecard || currentHole === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={programHref}
|
||||||
|
aria-label="Tilbake til program"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Turnering
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{tournamentName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto flex w-full max-w-3xl flex-1 items-center justify-center px-5 py-6">
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const played = playedHoleNumbers(session.hole_config)
|
||||||
|
const totalHoles = played.length
|
||||||
|
const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4
|
||||||
|
const registeredHoleNumbers = new Set(scorecard.holes.map((h) => h.hole_number))
|
||||||
|
|
||||||
|
const statusLabel = scorecard.status_text ?? "AS"
|
||||||
|
|
||||||
|
function strokeValue(holeNumber: number, unit: Unit): number | null {
|
||||||
|
const entry = (scorecard!.stroke_entries ?? []).find(
|
||||||
|
(e) =>
|
||||||
|
e.hole_number === holeNumber &&
|
||||||
|
e.team_side === unit.side &&
|
||||||
|
e.match_participant_id === unit.matchParticipantId,
|
||||||
|
)
|
||||||
|
return entry ? entry.gross_strokes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepStroke(unit: Unit, delta: number) {
|
||||||
|
const current = strokeValue(currentHole!, unit)
|
||||||
|
const base = current === null ? par : current
|
||||||
|
const next = Math.max(1, Math.min(20, base + delta))
|
||||||
|
void submitStroke(currentHole!, unit, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentResultEntry = (scorecard.hole_result_entries ?? []).find(
|
||||||
|
(e) => e.hole_number === currentHole,
|
||||||
|
)
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
setCurrentHole((h) => {
|
||||||
|
const idx = played.indexOf(h!)
|
||||||
|
return played[Math.max(0, idx - 1)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function goNext() {
|
||||||
|
setCurrentHole((h) => {
|
||||||
|
const idx = played.indexOf(h!)
|
||||||
|
return played[Math.min(played.length - 1, idx + 1)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIndividual = INDIVIDUAL_FORMATS.includes(session.format)
|
||||||
|
const isLast = played.indexOf(currentHole) >= played.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={programHref}
|
||||||
|
aria-label="Tilbake til program"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Turnering
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{tournamentName}
|
||||||
|
</h1>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]} · Match {match.sequence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border bg-card/40">
|
||||||
|
<div className="mx-auto grid w-full max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-2 px-5 py-3 sm:gap-4">
|
||||||
|
<TeamTag name={teams[0].name} color={teams[0].color ?? "#64748b"} align="left" />
|
||||||
|
{decided ? (
|
||||||
|
<div className="flex flex-col items-center px-1">
|
||||||
|
<span className="text-lg font-extrabold tracking-tight text-primary">Avgjort</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goPrev}
|
||||||
|
disabled={played.indexOf(currentHole) <= 0}
|
||||||
|
aria-label="Forrige hull"
|
||||||
|
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden="true" className="size-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-[84px] flex-col items-center">
|
||||||
|
<span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={isLast}
|
||||||
|
aria-label="Neste hull"
|
||||||
|
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TeamTag name={teams[1].name} color={teams[1].color ?? "#64748b"} align="right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{decided ? (
|
||||||
|
<DecidedView
|
||||||
|
teams={teams}
|
||||||
|
scorecard={scorecard}
|
||||||
|
holes={holes}
|
||||||
|
units={units}
|
||||||
|
scoringMode={session.scoring_mode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<nav aria-label="Velg hull" className="-mx-5 mb-4 overflow-x-auto px-5">
|
||||||
|
<div className="flex gap-2 pb-1">
|
||||||
|
{played.map((h) => {
|
||||||
|
const active = h === currentHole
|
||||||
|
const registered = registeredHoleNumbers.has(h)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={h}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentHole(h)}
|
||||||
|
aria-current={active ? "true" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-11 shrink-0 flex-col items-center justify-center rounded-2xl border text-sm font-bold tabular-nums transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
{registered && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full border-2 border-background",
|
||||||
|
active ? "bg-primary-foreground text-primary" : "bg-primary text-primary-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="size-2.5" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSummary((v) => !v)}
|
||||||
|
aria-expanded={showSummary}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">Vis full oversikt</span>
|
||||||
|
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{showSummary && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={session.scoring_mode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session.scoring_mode === "stroke" ? (
|
||||||
|
<section aria-label={`Hull ${currentHole}`}>
|
||||||
|
<div className="mb-4 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
||||||
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{units.map((unit) => {
|
||||||
|
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
||||||
|
const value = strokeValue(currentHole, unit)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={unit.id}
|
||||||
|
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"
|
||||||
|
style={{ borderLeftWidth: 6, borderLeftColor: teamColor }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: teamColor }} />
|
||||||
|
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
|
||||||
|
{unit.label}
|
||||||
|
</span>
|
||||||
|
{!isIndividual && (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">(delt ball)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stepStroke(unit, -1)}
|
||||||
|
aria-label={`Ett slag mindre for ${unit.label}`}
|
||||||
|
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-secondary text-secondary-foreground transition-colors hover:bg-accent active:scale-95"
|
||||||
|
>
|
||||||
|
<Minus aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-1 flex-col items-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-5xl font-extrabold leading-none tabular-nums",
|
||||||
|
value === null ? "text-muted-foreground/40" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value === null ? "–" : value}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-xs font-medium text-muted-foreground">
|
||||||
|
{value === null ? "Ikke registrert" : scoreToParLabel(value, par)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stepStroke(unit, 1)}
|
||||||
|
aria-label={`Ett slag mer for ${unit.label}`}
|
||||||
|
className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground transition-colors hover:bg-primary/90 active:scale-95"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section aria-label={`Hull ${currentHole}`}>
|
||||||
|
<div className="mb-4 flex items-baseline gap-2">
|
||||||
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
||||||
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "a", label: `${teams[0].name} vant`, color: teams[0].color, side: "a" as const },
|
||||||
|
{ key: "h", label: "Delt", color: undefined, side: null },
|
||||||
|
{ key: "b", label: `${teams[1].name} vant`, color: teams[1].color, side: "b" as const },
|
||||||
|
] as const
|
||||||
|
).map((opt) => {
|
||||||
|
const active = currentResultEntry
|
||||||
|
? currentResultEntry.winning_side === opt.side
|
||||||
|
: false
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => submitHoleResult(currentHole, opt.side)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"flex h-16 items-center justify-center gap-2 rounded-2xl border text-base font-bold tracking-tight transition-colors",
|
||||||
|
active
|
||||||
|
? opt.color
|
||||||
|
? "border-transparent text-primary-foreground"
|
||||||
|
: "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
style={active && opt.color ? { backgroundColor: opt.color } : undefined}
|
||||||
|
>
|
||||||
|
{opt.color && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-3.5 rounded-full"
|
||||||
|
style={{ backgroundColor: active ? undefined : opt.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{!isLast ? (
|
||||||
|
<Button type="button" onClick={goNext} className="h-14 w-full rounded-2xl text-base font-bold shadow-sm">
|
||||||
|
Neste hull
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSummary(true)}
|
||||||
|
className="h-14 w-full rounded-2xl text-base font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<Check aria-hidden="true" className="size-5" />
|
||||||
|
Ferdig
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Small components --------------------------------------------------------
|
||||||
|
|
||||||
|
function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) {
|
||||||
|
const right = align === "right"
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-w-0 items-center gap-2", right ? "flex-row-reverse text-right" : "text-left")}>
|
||||||
|
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||||
|
<span className="truncate text-sm font-bold tracking-tight text-foreground">{name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoleSummaryTable({
|
||||||
|
teams,
|
||||||
|
scorecard,
|
||||||
|
holes,
|
||||||
|
scoringMode,
|
||||||
|
}: {
|
||||||
|
teams: [ApiTeam, ApiTeam]
|
||||||
|
scorecard: ApiScorecard
|
||||||
|
holes: ApiHole[]
|
||||||
|
scoringMode: ScoringMode
|
||||||
|
}) {
|
||||||
|
if (scorecard.holes.length === 0) {
|
||||||
|
return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{scorecard.holes.map((h) => {
|
||||||
|
const par = holes.find((x) => x.hole_number === h.hole_number)?.par
|
||||||
|
return (
|
||||||
|
<li key={h.hole_number} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex size-8 items-center justify-center rounded-xl bg-secondary text-sm font-extrabold tabular-nums text-secondary-foreground">
|
||||||
|
{h.hole_number}
|
||||||
|
</span>
|
||||||
|
{par !== undefined && (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Par {par}</span>
|
||||||
|
)}
|
||||||
|
{scoringMode === "stroke" && (
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{grossPairLabel(scorecard, h.hole_number)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<OutcomeBadge teams={teams} result={h.result} />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string {
|
||||||
|
const entries = scorecard.stroke_entries ?? []
|
||||||
|
const sideGross = (side: "a" | "b"): string => {
|
||||||
|
const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes)
|
||||||
|
if (vals.length === 0) return "–"
|
||||||
|
return String(Math.min(...vals))
|
||||||
|
}
|
||||||
|
return `${sideGross("a")}–${sideGross("b")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) {
|
||||||
|
if (result === "halved") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
|
Delt
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const team = result === "a" ? teams[0] : teams[1]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold text-primary-foreground"
|
||||||
|
style={{ backgroundColor: team.color ?? "#64748b" }}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreToParLabel(value: number, par: number): string {
|
||||||
|
const diff = value - par
|
||||||
|
if (diff === 0) return "Par"
|
||||||
|
if (diff === -1) return "Birdie"
|
||||||
|
if (diff === -2) return "Eagle"
|
||||||
|
if (diff <= -3) return "Albatross"
|
||||||
|
if (diff === 1) return "Bogey"
|
||||||
|
if (diff === 2) return "Dobbel bogey"
|
||||||
|
return `+${diff}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecidedView({
|
||||||
|
teams,
|
||||||
|
scorecard,
|
||||||
|
holes,
|
||||||
|
units,
|
||||||
|
scoringMode,
|
||||||
|
}: {
|
||||||
|
teams: [ApiTeam, ApiTeam]
|
||||||
|
scorecard: ApiScorecard
|
||||||
|
holes: ApiHole[]
|
||||||
|
units: Unit[]
|
||||||
|
scoringMode: ScoringMode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl bg-primary px-5 py-5 text-primary-foreground shadow-sm">
|
||||||
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
|
||||||
|
<Trophy aria-hidden="true" className="size-7" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-lg font-extrabold tracking-tight">Matchen er avgjort</span>
|
||||||
|
<span className="text-sm text-primary-foreground/85 text-pretty">{scorecard.status_text ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 items-center gap-2 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[0].color ?? "#64748b" }} />
|
||||||
|
<span className="text-sm font-bold text-foreground text-pretty">{teams[0].name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-extrabold tabular-nums text-foreground">
|
||||||
|
{formatPoints(scorecard.points_side_a ?? 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-xs font-semibold uppercase tracking-wide text-muted-foreground">Poeng</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="flex flex-row-reverse items-center gap-2">
|
||||||
|
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[1].color ?? "#64748b" }} />
|
||||||
|
<span className="text-sm font-bold text-foreground text-pretty">{teams[1].name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-extrabold tabular-nums text-foreground">
|
||||||
|
{formatPoints(scorecard.points_side_b ?? 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={scoringMode} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
|
||||||
|
Resultatet kan ikke endres etter at matchen er avgjort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPoints(points: number) {
|
||||||
|
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue