teecup/frontend/components/round-groups-panel.tsx
Erol Haagenrud 9a9fcc84c0 Utslagsgrupper for individuelle turneringer: hvem spiller med hvem (ADR-088)
Tradisjonelt utslag (samme starthull, staggerte klokkeslett med fast
intervall), manuell gruppesammensetning med et forslag å justere, fast
gruppestørrelse. Fire ekte bugs funnet og fikset før utrulling via egen
scratch-verifisering (FK-korrupsjon, str/datetime-mismatch, tidssone,
React state). Migrasjon 084 lagt til, men IKKE anvendt mot ekte
teecup_db ennå -- venter på bekreftelse.
2026-08-18 15:43:39 +02:00

453 lines
19 KiB
TypeScript

"use client"
// Utslagsgrupper (2026-08-18) -- hvem spiller med hvem, klokka når, for
// én runde i en individuell turnering. Bruker: "Jeg må jo kunne
// bulksjekke hvem som skal spille fra hvor, med hvem, klokka når?"
// Design bekreftet: TRADISJONELT utslag (samme starthull for alle,
// rundens eget start_hole -- uendret her), staggerte klokkeslett per
// gruppe med fast intervall, MANUELL gruppesammensetning med et forslag
// å justere, FAST gruppestørrelse.
//
// Håndkodet direkte (ikke sendt via V0 først) -- bruker ba eksplisitt om
// funksjonen NÅ ("Jeg vil ha den nå, takk"), samme avveining som
// DuplicateStep i player-import-panel.tsx: en avgrenset, funksjonell
// arbeidsflate satt inn i en eksisterende flyt, bygget med de samme
// globale design-tokenene som resten av appen (border/bg-card/bg-primær
// osv.), ikke en forsøkt "ferdig polert" ny flaggskip-skjerm. Kan
// poleres videre via V0 senere om ønskelig.
import { useEffect, useState } from "react"
import { Clock, Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiGroupParticipant = {
round_participant_id: string
player_name: string
}
type ApiRoundGroup = {
id: string | null
sequence: number
tee_time: string | null
tee_time_override: string | null
participants: ApiGroupParticipant[]
}
type ApiRoundGroups = {
groups: ApiRoundGroup[]
ungrouped: ApiGroupParticipant[]
}
type ApiRoundInfo = {
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
}
function formatTeeTime(iso: string | null) {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
// datetime-local-inputet gir "YYYY-MM-DDTHH:mm" (ingen tidssone) -- server
// (Pydantic datetime-felt) godtar dette direkte, tolkes som lokal tid.
function toDatetimeLocalValue(iso: string | null) {
if (!iso) return ""
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ""
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const CONTROL =
"h-10 rounded-lg 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"
export function RoundGroupsPanel({
base,
roundId,
roundLabel,
onClose,
}: {
base: string
roundId: string
roundLabel: string
onClose: () => void
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
const [scheduledAtInput, setScheduledAtInput] = useState("")
const [intervalInput, setIntervalInput] = useState("")
const [groups, setGroups] = useState<ApiRoundGroup[]>([])
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
const [groupSize, setGroupSize] = useState("4")
async function loadAll() {
setLoading(true)
setError(null)
try {
const [roundRes, groupsRes] = await Promise.all([
fetch(`${base}/rounds`, { credentials: "include" }),
fetch(`${base}/rounds/${roundId}/groups`, { credentials: "include" }),
])
if (!roundRes.ok || !groupsRes.ok) throw new Error()
const rounds: (ApiRoundInfo & { id: string })[] = await roundRes.json()
const thisRound = rounds.find((r) => r.id === roundId) ?? null
const groupsData: ApiRoundGroups = await groupsRes.json()
setRoundInfo(thisRound)
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
setGroups(groupsData.groups)
setUngrouped(groupsData.ungrouped)
} catch {
setError("Klarte ikke å laste utslagsgruppene. Prøv igjen.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
async function saveSchedule() {
setError(null)
// <input type="datetime-local"> gir en TIDSSONELØS streng som betyr
// "organisatorens lokale klokkeslett" (f.eks. "10:00" i Norge). new
// Date(...) på en slik streng tolkes av nettleseren som LOKAL tid --
// .toISOString() konverterer deretter til riktig UTC-tidspunkt FØR
// sending. Uten dette steget ville "10:00" blitt lagret som 10:00 UTC
// (feil med hele tidssoneavviket, f.eks. 2 timer for norsk sommertid)
// -- funnet under egen scratch-verifisering av akkurat denne skjermen.
const scheduledAtIso = scheduledAtInput ? new Date(scheduledAtInput).toISOString() : null
const res = await fetch(`${base}/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
scheduled_at: scheduledAtIso,
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
}),
})
if (!res.ok) {
setError("Klarte ikke å lagre utslagstidspunktet.")
return
}
await loadAll()
}
async function suggest() {
const size = Number(groupSize)
if (!size || size < 1) return
const hasExistingWork = groups.some((g) => g.participants.length > 0)
if (hasExistingWork && !confirm("Dette erstatter det du eventuelt allerede har justert her (ikke lagret ennå). Fortsette?")) {
return
}
setError(null)
const res = await fetch(`${base}/rounds/${roundId}/groups/suggest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ group_size: size }),
})
if (!res.ok) {
setError("Klarte ikke å beregne et forslag.")
return
}
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage(null)
}
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: [] }])
}
function removeGroup(sequence: number) {
const group = groups.find((g) => g.sequence === sequence)
if (!group) return
setUngrouped((prev) => [...prev, ...group.participants])
setGroups((prev) => prev.filter((g) => g.sequence !== sequence))
}
// VIKTIG: `moved` må leses SYNKRONT fra det gjeldende (allerede rendrede)
// state-et, IKKE inne i en setState-oppdateringsfunksjon -- React kjører
// ikke oppdateringsfunksjoner umiddelbart, så en `let moved` satt inni én
// slik funksjon er fortsatt `null` når den leses rett etterpå i samme
// kall. Funnet under egen scratch-verifisering: en flyttet spiller
// forsvant sporløst (fjernet fra den gamle gruppen, aldri lagt til den
// nye) fordi nettopp dette skjedde.
function moveParticipant(participantId: string, toSequence: number | "ungrouped") {
const moved =
groups.flatMap((g) => g.participants).find((p) => p.round_participant_id === participantId) ??
ungrouped.find((p) => p.round_participant_id === participantId)
if (!moved) return
setGroups((prev) =>
prev.map((g) => {
if (g.sequence === toSequence) {
const withoutMoved = g.participants.filter((p) => p.round_participant_id !== participantId)
return { ...g, participants: [...withoutMoved, moved] }
}
return { ...g, participants: g.participants.filter((p) => p.round_participant_id !== participantId) }
}),
)
setUngrouped((prev) => {
const withoutMoved = prev.filter((p) => p.round_participant_id !== participantId)
return toSequence === "ungrouped" ? [...withoutMoved, moved] : withoutMoved
})
}
async function save() {
setSaving(true)
setError(null)
setSavedMessage(null)
try {
const res = await fetch(`${base}/rounds/${roundId}/groups`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
groups: groups.map((g) => ({
sequence: g.sequence,
tee_time_override: null,
round_participant_ids: g.participants.map((p) => p.round_participant_id),
})),
}),
})
if (!res.ok) throw new Error()
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage("Lagret.")
} catch {
setError("Klarte ikke å lagre utslagsgruppene. Prøv igjen.")
} finally {
setSaving(false)
}
}
const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b)
function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) {
return (
<label className="flex items-center gap-2">
<span className="sr-only">Gruppe</span>
<select
value={currentSequence}
onChange={(e) => {
const v = e.target.value
moveParticipant(participantId, v === "ungrouped" ? "ungrouped" : Number(v))
}}
className={cn(CONTROL, "h-9 px-2 text-xs")}
>
<option value="ungrouped">Ugruppert</option>
{allSequences.map((seq) => (
<option key={seq} value={seq}>
Gruppe {seq}
</option>
))}
</select>
</label>
)
}
return (
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background px-5 py-4 sm:px-8">
<div className="flex flex-col">
<h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1>
<p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8">
{error && (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
)}
{loading ? (
<p className="text-sm font-medium text-muted-foreground">Laster</p>
) : (
<>
<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>
</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">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Grupper</h2>
<p className="text-sm text-muted-foreground">
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
</p>
</div>
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
<input
type="number"
min={1}
max={12}
value={groupSize}
onChange={(e) => setGroupSize(e.target.value)}
className={cn(CONTROL, "w-20")}
/>
</label>
<button
type="button"
onClick={() => void suggest()}
className="inline-flex h-10 items-center justify-center rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Foreslå grupper
</button>
</div>
</div>
<div className="flex flex-col gap-3">
{groups.length === 0 && (
<p className="text-sm font-medium text-muted-foreground">
Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".
</p>
)}
{groups
.slice()
.sort((a, b) => 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">
<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>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
</span>
</div>
<button
type="button"
onClick={() => removeGroup(g.sequence)}
aria-label={`Fjern gruppe ${g.sequence}`}
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</div>
{g.participants.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">Ingen spillere i denne gruppen ennå.</p>
) : (
<ul className="flex flex-col gap-2">
{g.participants.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<GroupSelect participantId={p.round_participant_id} currentSequence={g.sequence} />
</li>
))}
</ul>
)}
</div>
))}
<button
type="button"
onClick={addEmptyGroup}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dashed border-border text-sm font-bold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Ny gruppe
</button>
</div>
<div className="border-t border-border pt-3">
<h3 className="mb-2 text-sm font-bold text-foreground">
Ugrupperte ({ungrouped.length})
</h3>
{ungrouped.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">Alle deltakere er i en gruppe.</p>
) : (
<ul className="flex flex-col gap-2">
{ungrouped.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
{allSequences.length > 0 ? (
<GroupSelect participantId={p.round_participant_id} currentSequence="ungrouped" />
) : (
<span className="text-xs text-muted-foreground">Opprett en gruppe først</span>
)}
</li>
))}
</ul>
)}
</div>
</section>
<div className="flex items-center justify-end gap-3 pb-6">
{savedMessage && <span className="text-sm font-bold text-tee-strong">{savedMessage}</span>}
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="inline-flex h-12 items-center justify-center rounded-xl bg-primary px-6 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre utslagsgrupper"}
</button>
</div>
</>
)}
</div>
</div>
)
}