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 sequence: number
tee_time: string | null tee_time: string | null
tee_time_override: 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[] participants: ApiGroupParticipant[]
} }
@ -42,6 +46,7 @@ type ApiRoundInfo = {
scheduled_at: string | null scheduled_at: string | null
tee_interval_minutes: number | null tee_interval_minutes: number | null
start_hole: number start_hole: number
start_mode: string
} }
function formatTeeTime(iso: string | null) { function formatTeeTime(iso: string | null) {
@ -83,6 +88,10 @@ export function RoundGroupsPanel({
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null) const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
const [scheduledAtInput, setScheduledAtInput] = useState("") const [scheduledAtInput, setScheduledAtInput] = useState("")
const [intervalInput, setIntervalInput] = 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 [groups, setGroups] = useState<ApiRoundGroup[]>([])
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([]) const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
@ -103,6 +112,7 @@ export function RoundGroupsPanel({
setRoundInfo(thisRound) setRoundInfo(thisRound)
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null)) setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "") setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
setStartModeInput(thisRound?.start_mode === "shotgun" ? "shotgun" : "consecutive")
setGroups(groupsData.groups) setGroups(groupsData.groups)
setUngrouped(groupsData.ungrouped) setUngrouped(groupsData.ungrouped)
} catch { } catch {
@ -134,6 +144,7 @@ export function RoundGroupsPanel({
body: JSON.stringify({ body: JSON.stringify({
scheduled_at: scheduledAtIso, scheduled_at: scheduledAtIso,
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null, tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
start_mode: startModeInput,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
@ -169,7 +180,15 @@ export function RoundGroupsPanel({
function addEmptyGroup() { function addEmptyGroup() {
const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1 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) { function removeGroup(sequence: number) {
@ -220,6 +239,7 @@ export function RoundGroupsPanel({
groups: groups.map((g) => ({ groups: groups.map((g) => ({
sequence: g.sequence, sequence: g.sequence,
tee_time_override: null, tee_time_override: null,
start_hole: g.start_hole,
round_participant_ids: g.participants.map((p) => p.round_participant_id), 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"> <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> <h2 className="text-base font-bold text-foreground">Utslagsform</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty"> <div
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden), role="radiogroup"
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette aria-label="Utslagsform"
antall minutter senere, og videre. className="inline-flex w-fit rounded-xl border border-border bg-background p-1"
</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> { value: "consecutive" as const, label: "Løpende start" },
<input { value: "shotgun" as const, label: "Shotgun" },
type="datetime-local" ]
value={scheduledAtInput} ).map((opt) => (
onChange={(e) => setScheduledAtInput(e.target.value)} <button
className={CONTROL} key={opt.value}
/> type="button"
</label> role="radio"
<label className="flex flex-1 flex-col gap-1.5"> aria-checked={startModeInput === opt.value}
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span> onClick={() => setStartModeInput(opt.value)}
<input className={cn(
type="number" "h-10 rounded-lg px-4 text-sm font-bold transition-colors",
min={1} startModeInput === opt.value
value={intervalInput} ? "bg-primary text-primary-foreground"
onChange={(e) => setIntervalInput(e.target.value)} : "text-muted-foreground hover:text-foreground",
placeholder="f.eks. 10" )}
className={CONTROL} >
/> {opt.label}
</label> </button>
<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> </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>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"> <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 {groups
.slice() .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) => ( .map((g) => (
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4"> <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="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"> <span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{g.sequence} {g.sequence}
</span> </span>
@ -391,6 +481,20 @@ export function RoundGroupsPanel({
<Clock aria-hidden="true" className="size-4 text-muted-foreground" /> <Clock aria-hidden="true" className="size-4 text-muted-foreground" />
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"} {formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
</span> </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> </div>
<button <button
type="button" type="button"

View file

@ -3,7 +3,7 @@
import type React from "react" import type React from "react"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" 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 { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -61,6 +61,10 @@ type ApiSession = {
course_id: string course_id: string
points_per_match: number points_per_match: number
scoring_mode: ScoringMode 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[] locked_team_ids: string[]
} }
@ -85,6 +89,9 @@ type ApiMatch = {
points_side_b: number | null points_side_b: number | null
leading_side: "a" | "b" | null leading_side: "a" | "b" | null
tee_time: string | 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[] participants: ApiParticipant[]
} }
@ -190,7 +197,20 @@ export function SessionBlindDraw({
} }
}, [organizationId, tournamentId, sessionId]) }, [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( const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1), () => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches], [matches],
@ -203,7 +223,7 @@ export function SessionBlindDraw({
if (res.ok) setMatches(await res.json()) if (res.ok) setMatches(await res.json())
} }
async function addMatch() { async function addMatch(startHole?: number) {
if (!teams) return if (!teams) return
setError(null) setError(null)
try { try {
@ -211,7 +231,12 @@ export function SessionBlindDraw({
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", 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}`) if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json() const created: ApiMatch = await res.json()
@ -413,6 +438,7 @@ export function SessionBlindDraw({
<RevealedView <RevealedView
teams={teams} teams={teams}
matches={sortedMatches} matches={sortedMatches}
startMode={session.start_mode}
scorecardHrefFor={(matchId) => scorecardHrefFor={(matchId) =>
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` `/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
} }
@ -430,6 +456,7 @@ export function SessionBlindDraw({
matches={sortedMatches} matches={sortedMatches}
perSide={maxSlotsPerSide(session.format)} perSide={maxSlotsPerSide(session.format)}
format={session.format} format={session.format}
startMode={session.start_mode}
locked={session.locked_team_ids.includes(team.id)} locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds} usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
@ -523,6 +550,7 @@ function TeamColumn({
matches, matches,
perSide, perSide,
format, format,
startMode,
locked, locked,
usedPlayerIds, usedPlayerIds,
onAddParticipant, onAddParticipant,
@ -542,6 +570,9 @@ function TeamColumn({
matches: ApiMatch[] matches: ApiMatch[]
perSide: number perSide: number
format: Format 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 locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string> usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: ( onAddParticipant: (
@ -552,7 +583,7 @@ function TeamColumn({
lineupOrder?: number | null, lineupOrder?: number | null,
) => void ) => void
onRemoveParticipant: (matchId: string, participantId: string) => void onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: () => void onAddMatch: (startHole?: number) => void
complete: boolean complete: boolean
confirming: boolean confirming: boolean
onRequestLock: () => void onRequestLock: () => void
@ -560,6 +591,7 @@ function TeamColumn({
onConfirmLock: () => void onConfirmLock: () => void
}) { }) {
const color = team.color ?? "#64748b" const color = team.color ?? "#64748b"
const [newMatchStartHole, setNewMatchStartHole] = useState("")
return ( return (
<section <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"> <span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence} {match.sequence}
</span> </span>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> {startMode === "shotgun" ? (
<Clock aria-hidden="true" className="size-4" /> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"} <Flag aria-hidden="true" className="size-4" />
</span> {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>
<div className="flex flex-col gap-2 border-t border-border pt-3"> <div className="flex flex-col gap-2 border-t border-border pt-3">
@ -658,10 +697,31 @@ function TeamColumn({
})} })}
</div> </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 && ( {!locked && (
<button <button
type="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" 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" /> <Plus aria-hidden="true" className="size-4" />
@ -827,10 +887,12 @@ function AddSlotForm({
function RevealedView({ function RevealedView({
teams, teams,
matches, matches,
startMode,
scorecardHrefFor, scorecardHrefFor,
}: { }: {
teams: [ApiTeam, ApiTeam] teams: [ApiTeam, ApiTeam]
matches: ApiMatch[] matches: ApiMatch[]
startMode: "consecutive" | "shotgun"
scorecardHrefFor: (matchId: string) => string scorecardHrefFor: (matchId: string) => string
}) { }) {
return ( return (
@ -865,6 +927,11 @@ function RevealedView({
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />} {decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text} {match.status_text}
</span> </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"> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" /> <Clock aria-hidden="true" className="size-4" />

View file

@ -101,6 +101,11 @@ type ApiSession = {
scheduled_at: string | null scheduled_at: string | null
tee_interval_minutes: number | null tee_interval_minutes: number | null
start_hole: number 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[] locked_team_ids: string[]
revealed: boolean revealed: boolean
} }
@ -718,14 +723,25 @@ function SessionCard({
</div> </div>
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3"> <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 ? ( {session.scheduled_at ? (
<div className="flex items-start gap-2 text-sm text-foreground"> <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" /> <CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-pretty"> <span className="text-pretty">
Første utslag {formatDateTime(session.scheduled_at)} {session.start_mode === "shotgun"
{session.tee_interval_minutes ? `Felles utslag ${formatDateTime(session.scheduled_at)}`
? `, ${session.tee_interval_minutes} min mellom hver` : `Første utslag ${formatDateTime(session.scheduled_at)}${
: ""} session.tee_interval_minutes ? `, ${session.tee_interval_minutes} min mellom hver` : ""
}`}
</span> </span>
</div> </div>
) : ( ) : (
@ -734,7 +750,7 @@ function SessionCard({
<span>Tidspunkt ikke satt</span> <span>Tidspunkt ikke satt</span>
</div> </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"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4 shrink-0" /> <Flag aria-hidden="true" className="size-4 shrink-0" />
<span>Starter hull {session.start_hole}</span> <span>Starter hull {session.start_hole}</span>
@ -823,6 +839,12 @@ function EditSessionForm({
const [teeInterval, setTeeInterval] = useState( const [teeInterval, setTeeInterval] = useState(
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "", 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 [courseId, setCourseId] = useState(session.course_id)
const [courseName, setCourseName] = useState(course?.name ?? "") const [courseName, setCourseName] = useState(course?.name ?? "")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
@ -841,8 +863,10 @@ function EditSessionForm({
name: name.trim() || null, name: name.trim() || null,
points_per_match: pointsValue, points_per_match: pointsValue,
start_hole: startHoleValue, start_hole: startHoleValue,
start_mode: startMode,
scheduled_at: hasTime ? scheduledAt : null, 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, course_id: courseId,
}) })
setSubmitting(false) setSubmitting(false)
@ -895,6 +919,36 @@ function EditSessionForm({
/> />
</div> </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="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold"> <Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
@ -911,26 +965,28 @@ function EditSessionForm({
className="h-12 rounded-2xl text-base" className="h-12 rounded-2xl text-base"
/> />
</div> </div>
<div className="flex flex-col gap-2"> {startMode === "consecutive" && (
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold"> <div className="flex flex-col gap-2">
Starthull <Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
</Label> Starthull
<Input </Label>
id={`edit-starthole-${uid}`} <Input
inputMode="numeric" id={`edit-starthole-${uid}`}
type="number" inputMode="numeric"
min="1" type="number"
max="18" min="1"
value={startHole} max="18"
onChange={(e) => setStartHole(e.target.value)} value={startHole}
className="h-12 rounded-2xl text-base" onChange={(e) => setStartHole(e.target.value)}
/> className="h-12 rounded-2xl text-base"
</div> />
</div>
)}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold"> <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> <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label> </Label>
<Input <Input
@ -942,7 +998,7 @@ function EditSessionForm({
/> />
</div> </div>
{hasTime && ( {hasTime && startMode === "consecutive" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold"> <Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
Minutter mellom hver flight Minutter mellom hver flight