Shotgun-start: frontend (individuell + Cup-format)

Løpende/Shotgun-veksler i rundens gruppeoppsett og øktoppsett, per-
gruppe/per-match starthull-felt i shotgun-modus. Fant og rettet en
reell bug underveis: begge skjermene hadde en hardkodet sequence-sort
som stille overstyrte backendens starthull-sortering for shotgun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-21 08:59:54 +02:00
parent f1575c9b42
commit 9f81797324
3 changed files with 297 additions and 70 deletions

View file

@ -30,6 +30,10 @@ type ApiRoundGroup = {
sequence: number
tee_time: string | null
tee_time_override: string | null
// Kun meningsfullt når runden er 'shotgun' (migrasjon 088, ADR-096) --
// `null` for en 'consecutive'-runde, som fortsatt bruker rundens EGET
// delte start_hole (uendret).
start_hole: number | null
participants: ApiGroupParticipant[]
}
@ -42,6 +46,7 @@ type ApiRoundInfo = {
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
start_mode: string
}
function formatTeeTime(iso: string | null) {
@ -83,6 +88,10 @@ export function RoundGroupsPanel({
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
const [scheduledAtInput, setScheduledAtInput] = useState("")
const [intervalInput, setIntervalInput] = useState("")
// Shotgun-start (migrasjon 088, ADR-096) -- bruker: "Det må komme frem
// om det er Shotgun eller løpende start... Sorteringen av startlisten
// gjøres etter utslagsform." 'consecutive' = dagens oppførsel uendret.
const [startModeInput, setStartModeInput] = useState<"consecutive" | "shotgun">("consecutive")
const [groups, setGroups] = useState<ApiRoundGroup[]>([])
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
@ -103,6 +112,7 @@ export function RoundGroupsPanel({
setRoundInfo(thisRound)
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
setStartModeInput(thisRound?.start_mode === "shotgun" ? "shotgun" : "consecutive")
setGroups(groupsData.groups)
setUngrouped(groupsData.ungrouped)
} catch {
@ -134,6 +144,7 @@ export function RoundGroupsPanel({
body: JSON.stringify({
scheduled_at: scheduledAtIso,
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
start_mode: startModeInput,
}),
})
if (!res.ok) {
@ -169,7 +180,15 @@ export function RoundGroupsPanel({
function addEmptyGroup() {
const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1
setGroups((prev) => [...prev, { id: null, sequence: nextSequence, tee_time: null, tee_time_override: null, participants: [] }])
setGroups((prev) => [
...prev,
{ id: null, sequence: nextSequence, tee_time: null, tee_time_override: null, start_hole: null, participants: [] },
])
}
function setGroupStartHole(sequence: number, value: string) {
const startHole = value.trim() ? Number(value) : null
setGroups((prev) => prev.map((g) => (g.sequence === sequence ? { ...g, start_hole: startHole } : g)))
}
function removeGroup(sequence: number) {
@ -220,6 +239,7 @@ export function RoundGroupsPanel({
groups: groups.map((g) => ({
sequence: g.sequence,
tee_time_override: null,
start_hole: g.start_hole,
round_participant_ids: g.participants.map((p) => p.round_participant_id),
})),
}),
@ -297,41 +317,99 @@ export function RoundGroupsPanel({
) : (
<>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<h2 className="text-base font-bold text-foreground">Første utslag og intervall</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden),
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette
antall minutter senere, og videre.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Første utslag</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
<input
type="number"
min={1}
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="f.eks. 10"
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
<h2 className="text-base font-bold text-foreground">Utslagsform</h2>
<div
role="radiogroup"
aria-label="Utslagsform"
className="inline-flex w-fit rounded-xl border border-border bg-background p-1"
>
{(
[
{ value: "consecutive" as const, label: "Løpende start" },
{ value: "shotgun" as const, label: "Shotgun" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={startModeInput === opt.value}
onClick={() => setStartModeInput(opt.value)}
className={cn(
"h-10 rounded-lg px-4 text-sm font-bold transition-colors",
startModeInput === opt.value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
{startModeInput === "consecutive" ? (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden),
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette
antall minutter senere, og videre.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Første utslag</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
<input
type="number"
min={1}
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="f.eks. 10"
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
</div>
</>
) : (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper går ut SAMTIDIG, hver fra sitt eget starthull -- sett starthull per
gruppe nedenfor. Intervallet gjelder ikke i denne modusen.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Felles utslagstidspunkt</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
</div>
</>
)}
</section>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
@ -379,11 +457,23 @@ export function RoundGroupsPanel({
)}
{groups
.slice()
.sort((a, b) => a.sequence - b.sequence)
.sort((a, b) =>
// Shotgun (migrasjon 088, ADR-096): vis sortert etter
// STARTHULL, ikke sequence -- speiler backend sin
// tilsvarende sortering (_fetch_round_groups), men må
// også gjelde HER siden lokale, ulagrede endringer
// (f.eks. et nettopp justert starthull) ellers ikke
// ville flyttet gruppen i visningen før neste lagring/
// reload.
startModeInput === "shotgun"
? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) ||
(a.start_hole ?? 0) - (b.start_hole ?? 0)
: a.sequence - b.sequence,
)
.map((g) => (
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{g.sequence}
</span>
@ -391,6 +481,20 @@ export function RoundGroupsPanel({
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
</span>
{startModeInput === "shotgun" && (
<label className="flex items-center gap-1.5">
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
<input
type="number"
min={1}
max={18}
value={g.start_hole ?? ""}
onChange={(e) => setGroupStartHole(g.sequence, e.target.value)}
placeholder="Hull"
className={cn(CONTROL, "h-9 w-20 px-2 text-xs")}
/>
</label>
)}
</div>
<button
type="button"

View file

@ -3,7 +3,7 @@
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { ArrowLeft, Clock, EyeOff, Flag, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@ -61,6 +61,10 @@ type ApiSession = {
course_id: string
points_per_match: number
scoring_mode: ScoringMode
// Shotgun-start (migrasjon 088, ADR-096) -- 'shotgun': matchene under
// legges til med eget starthull hver (se addMatch), ikke felles
// klokkeslett-staggering.
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
}
@ -85,6 +89,9 @@ type ApiMatch = {
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null
// Kun meningsfullt (og satt av organisator ved opprettelse) når økten
// er 'shotgun' (migrasjon 088, ADR-096).
start_hole: number | null
participants: ApiParticipant[]
}
@ -190,7 +197,20 @@ export function SessionBlindDraw({
}
}, [organizationId, tournamentId, sessionId])
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
// Shotgun (migrasjon 088, ADR-096): vis sortert etter STARTHULL, ikke
// sequence -- speiler backend sin tilsvarende sortering (fetch_matches
// i matches.py), men må også gjelde HER siden lokale, nettopp lagt-til
// matcher ellers ikke ville vist i riktig rekkefølge før neste reload.
const sortedMatches = useMemo(
() =>
[...matches].sort((a, b) =>
session?.start_mode === "shotgun"
? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) ||
(a.start_hole ?? 0) - (b.start_hole ?? 0)
: a.sequence - b.sequence,
),
[matches, session?.start_mode],
)
const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches],
@ -203,7 +223,7 @@ export function SessionBlindDraw({
if (res.ok) setMatches(await res.json())
}
async function addMatch() {
async function addMatch(startHole?: number) {
if (!teams) return
setError(null)
try {
@ -211,7 +231,12 @@ export function SessionBlindDraw({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
body: JSON.stringify({
sequence: nextSequence,
team_a_id: teams[0].id,
team_b_id: teams[1].id,
start_hole: startHole ?? null,
}),
})
if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json()
@ -413,6 +438,7 @@ export function SessionBlindDraw({
<RevealedView
teams={teams}
matches={sortedMatches}
startMode={session.start_mode}
scorecardHrefFor={(matchId) =>
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
}
@ -430,6 +456,7 @@ export function SessionBlindDraw({
matches={sortedMatches}
perSide={maxSlotsPerSide(session.format)}
format={session.format}
startMode={session.start_mode}
locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant}
@ -523,6 +550,7 @@ function TeamColumn({
matches,
perSide,
format,
startMode,
locked,
usedPlayerIds,
onAddParticipant,
@ -542,6 +570,9 @@ function TeamColumn({
matches: ApiMatch[]
perSide: number
format: Format
// Shotgun-start (migrasjon 088, ADR-096) -- styrer om "Legg til match"
// spør om starthull (shotgun) eller ikke (consecutive, som før).
startMode: "consecutive" | "shotgun"
locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: (
@ -552,7 +583,7 @@ function TeamColumn({
lineupOrder?: number | null,
) => void
onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: () => void
onAddMatch: (startHole?: number) => void
complete: boolean
confirming: boolean
onRequestLock: () => void
@ -560,6 +591,7 @@ function TeamColumn({
onConfirmLock: () => void
}) {
const color = team.color ?? "#64748b"
const [newMatchStartHole, setNewMatchStartHole] = useState("")
return (
<section
@ -595,10 +627,17 @@ function TeamColumn({
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
{startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
@ -658,10 +697,31 @@ function TeamColumn({
})}
</div>
{!locked && startMode === "shotgun" && (
<div className="flex items-center gap-2">
<label className="flex flex-1 items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
<input
type="number"
min={1}
max={18}
value={newMatchStartHole}
onChange={(e) => setNewMatchStartHole(e.target.value)}
placeholder="Hull"
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
</div>
)}
{!locked && (
<button
type="button"
onClick={onAddMatch}
onClick={() => {
const hole = newMatchStartHole.trim() ? Number(newMatchStartHole) : undefined
onAddMatch(hole)
setNewMatchStartHole("")
}}
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
@ -827,10 +887,12 @@ function AddSlotForm({
function RevealedView({
teams,
matches,
startMode,
scorecardHrefFor,
}: {
teams: [ApiTeam, ApiTeam]
matches: ApiMatch[]
startMode: "consecutive" | "shotgun"
scorecardHrefFor: (matchId: string) => string
}) {
return (
@ -865,6 +927,11 @@ function RevealedView({
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text}
</span>
) : startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />

View file

@ -101,6 +101,11 @@ type ApiSession = {
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
// Shotgun-start (migrasjon 088, ADR-096) -- 'consecutive' (standard) er
// dagens oppførsel uendret. 'shotgun': alle matcher i økten går ut
// SAMTIDIG, fra hvert sitt starthull (match.start_hole, satt i
// trekning-skjermen) i stedet for staggert klokkeslett.
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
revealed: boolean
}
@ -718,14 +723,25 @@ function SessionCard({
</div>
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span
className={cn(
"rounded-full px-2 py-0.5",
session.start_mode === "shotgun" ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
{session.start_mode === "shotgun" ? "Shotgun" : "Løpende start"}
</span>
</div>
{session.scheduled_at ? (
<div className="flex items-start gap-2 text-sm text-foreground">
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-pretty">
Første utslag {formatDateTime(session.scheduled_at)}
{session.tee_interval_minutes
? `, ${session.tee_interval_minutes} min mellom hver`
: ""}
{session.start_mode === "shotgun"
? `Felles utslag ${formatDateTime(session.scheduled_at)}`
: `Første utslag ${formatDateTime(session.scheduled_at)}${
session.tee_interval_minutes ? `, ${session.tee_interval_minutes} min mellom hver` : ""
}`}
</span>
</div>
) : (
@ -734,7 +750,7 @@ function SessionCard({
<span>Tidspunkt ikke satt</span>
</div>
)}
{session.start_hole !== 1 && (
{session.start_mode === "consecutive" && session.start_hole !== 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>Starter hull {session.start_hole}</span>
@ -823,6 +839,12 @@ function EditSessionForm({
const [teeInterval, setTeeInterval] = useState(
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
)
// Shotgun-start (migrasjon 088, ADR-096) -- bruker: "Det må komme frem
// om det er Shotgun eller løpende start... Sorteringen av startlisten
// gjøres etter utslagsform." 'shotgun': starthull settes per MATCH i
// trekning-skjermen i stedet (økten sitt eget start_hole blir da
// urelevant, skjules under).
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
const [courseId, setCourseId] = useState(session.course_id)
const [courseName, setCourseName] = useState(course?.name ?? "")
const [submitting, setSubmitting] = useState(false)
@ -841,8 +863,10 @@ function EditSessionForm({
name: name.trim() || null,
points_per_match: pointsValue,
start_hole: startHoleValue,
start_mode: startMode,
scheduled_at: hasTime ? scheduledAt : null,
tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
tee_interval_minutes:
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
course_id: courseId,
})
setSubmitting(false)
@ -895,6 +919,36 @@ function EditSessionForm({
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Utslagsform</Label>
<div role="radiogroup" aria-label="Utslagsform" className="inline-flex w-fit rounded-2xl border border-border bg-background p-1">
{(
[
{ value: "consecutive" as const, label: "Løpende start" },
{ value: "shotgun" as const, label: "Shotgun" },
]
).map((opt) => (
<Button
key={opt.value}
type="button"
role="radio"
aria-checked={startMode === opt.value}
variant={startMode === opt.value ? "default" : "ghost"}
onClick={() => setStartMode(opt.value)}
className="h-10 rounded-xl px-4 text-sm font-bold"
>
{opt.label}
</Button>
))}
</div>
{startMode === "shotgun" && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle matcher i økten går ut samtidig -- starthull settes per match i trekning-skjermen
i stedet for her.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
@ -911,26 +965,28 @@ function EditSessionForm({
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
Starthull
</Label>
<Input
id={`edit-starthole-${uid}`}
inputMode="numeric"
type="number"
min="1"
max="18"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
{startMode === "consecutive" && (
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
Starthull
</Label>
<Input
id={`edit-starthole-${uid}`}
inputMode="numeric"
type="number"
min="1"
max="18"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
Startdato og -klokkeslett{" "}
{startMode === "shotgun" ? "Felles utslagstidspunkt" : "Startdato og -klokkeslett"}{" "}
<span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
@ -942,7 +998,7 @@ function EditSessionForm({
/>
</div>
{hasTime && (
{hasTime && startMode === "consecutive" && (
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
Minutter mellom hver flight