"use client" import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, CalendarClock, Check, ChevronDown, ChevronRight, Clock, Flag, MapPin, MoreVertical, Pencil, Plus, Sparkles, Trash2, Users, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) - // 'scramble' finnes IKKE som egen verdi i API-et -- skjemaets CHECK-constraint // (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever // scramble_2/scramble_4 (antall spillere per side er del av selve formatet, // ikke en egen bryter). // Fire nye org-lagturnering-formater (2026-07-30, se ADR/plan) -- flatt- // felt-formatene (Københavner/BBB/Flag) hører ALDRI hjemme her (ADR-011s // to-lags-modell passer strukturelt ikke), kun individuelle org-turneringer. type SessionFormat = | "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" | "chapman" | "shamble" | "money_ball" | "high_low_high" type HoleConfig = "full_18" | "front_9" | "back_9" type ScoringMode = "stroke" | "hole_result" // Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren // -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined" // eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT). Chapman er // delt-ball (som foursome); shamble/money_ball/high_low_high er // individuell-ball (som fourball) selv om alle fire er to-sidede formater. const SIDE_IS_UNIT_FORMAT = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]) type ApiCourse = { id: string name: string source: string } type ApiSession = { id: string sequence: number name: string | null format: SessionFormat hole_config: HoleConfig course_id: string points_per_match: number allowance_override: Record | null scoring_mode: ScoringMode scheduled_at: string | null tee_interval_minutes: number | null start_hole: number locked_team_ids: string[] revealed: boolean } // --- Norwegian display labels ---------------------------------------------- const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2 spillere)", scramble_4: "Scramble (4 spillere)", fourball: "Fourball", singles: "Singel", chapman: "Chapman", shamble: "Shamble", money_ball: "Money Ball", high_low_high: "High-low-high", } const HOLE_LABELS: Record = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9", } const SCORING_LABELS: Record = { stroke: "Slag for slag", hole_result: "Kun hullresultat", } const FORMAT_OPTIONS = Object.keys(FORMAT_LABELS) as SessionFormat[] const HOLE_OPTIONS = Object.keys(HOLE_LABELS) as HoleConfig[] const SCORING_OPTIONS = Object.keys(SCORING_LABELS) as ScoringMode[] // --- Component ------------------------------------------------------------- export function TournamentProgram({ organizationId, tournamentId, tournamentName, }: { organizationId: string tournamentId: string tournamentName: string }) { const [sessions, setSessions] = useState([]) const [courses, setCourses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let cancelled = false async function load() { try { const [sessionsRes, coursesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include", }), fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }), ]) if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed") const sessionsData: ApiSession[] = await sessionsRes.json() const coursesData: ApiCourse[] = await coursesRes.json() if (cancelled) return setSessions(sessionsData) setCourses(coursesData) } catch { if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [organizationId, tournamentId]) const coursesById = useMemo(() => { const map = new Map() for (const c of courses) map.set(c.id, c) return map }, [courses]) const sorted = useMemo(() => [...sessions].sort((a, b) => a.sequence - b.sequence), [sessions]) const nextSequence = useMemo( () => (sessions.length === 0 ? 1 : Math.max(...sessions.map((s) => s.sequence)) + 1), [sessions], ) async function ensureCourseId(existingId: string | null, newName: string): Promise { if (existingId) return existingId const res = await fetch(`/orgs/${organizationId}/courses`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name: newName }), }) if (!res.ok) throw new Error(`create course: ${res.status}`) const created: ApiCourse = await res.json() setCourses((prev) => [...prev, created]) return created.id } async function addSession(input: { name?: string format: SessionFormat holeConfig: HoleConfig scoringMode: ScoringMode pointsPerMatch: number scheduledAt?: string teeIntervalMinutes?: number startHole: number courseId: string | null newCourseName: string allowanceOverride: Record | null shambleBestN: number | null }) { setError(null) try { const courseId = await ensureCourseId(input.courseId, input.newCourseName) const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ sequence: nextSequence, name: input.name ?? null, format: input.format, hole_config: input.holeConfig, course_id: courseId, points_per_match: input.pointsPerMatch, allowance_override: input.allowanceOverride, scoring_mode: input.scoringMode, scheduled_at: input.scheduledAt ?? null, tee_interval_minutes: input.teeIntervalMinutes ?? null, start_hole: input.startHole, shamble_best_n: input.format === "shamble" ? input.shambleBestN : null, }), }) if (!res.ok) throw new Error(`create session: ${res.status}`) const created: ApiSession = await res.json() setSessions((prev) => [...prev, created]) } catch { setError("Klarte ikke å opprette økten. Prøv igjen.") } } async function updateSession(sessionId: string, patch: Record): Promise { setError(null) try { const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(patch), }) if (!res.ok) throw new Error(`update session: ${res.status}`) const updated: ApiSession = await res.json() setSessions((prev) => prev.map((s) => (s.id === sessionId ? updated : s))) return true } catch { setError("Klarte ikke å oppdatere økten. Prøv igjen.") return false } } async function deleteSession(sessionId: string): Promise { setError(null) try { const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, { method: "DELETE", credentials: "include", }) if (res.status === 204) { setSessions((prev) => prev.filter((s) => s.id !== sessionId)) return null } const body = await res.json().catch(() => null) return body?.detail?.message ?? "Klarte ikke å slette økten. Prøv igjen." } catch { return "Klarte ikke å slette økten. Prøv igjen." } } const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` return (

Program

Sett opp turneringens økter i rekkefølge. Hver økt er én runde med et gitt format og tidspunkt.

{error && (

{error}

)} {loading ? (
) : ( <> {sorted.length === 0 ? ( ) : ( <> s.scheduled_at !== null).length} />
    {sorted.map((session, index) => ( setCourses((prev) => [...prev, course])} onSave={(patch) => updateSession(session.id, patch)} onDelete={() => deleteSession(session.id)} /> ))}
)}
setCourses((prev) => [...prev, course])} onCreate={addSession} />
)}
) } // --- Session card (timeline item) ------------------------------------------ function SessionCard({ session, course, courses, organizationId, isLast, href, onCourseImported, onSave, onDelete, }: { session: ApiSession course: ApiCourse | null courses: ApiCourse[] organizationId: string isLast: boolean href: string onCourseImported: (course: ApiCourse) => void onSave: (patch: Record) => Promise onDelete: () => Promise }) { const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view") const [deleteError, setDeleteError] = useState(null) const [deleting, setDeleting] = useState(false) const title = session.name?.trim() || `Økt ${session.sequence}` async function handleDelete() { setDeleting(true) const message = await onDelete() setDeleting(false) if (message) { setDeleteError(message) } else { setMode("view") } } return (
  • {session.sequence}
    {!isLast && {mode === "editing" ? ( setMode("view")} onSave={async (patch) => { const ok = await onSave(patch) if (ok) setMode("view") }} /> ) : (

    {title}

    {FORMAT_LABELS[session.format]} {HOLE_LABELS[session.hole_config]} {SCORING_LABELS[session.scoring_mode]} {formatPoints(session.points_per_match)} poeng {course && {course.name}}
    setMode("editing")} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > { setDeleteError(null) setMode("confirmingDelete") }} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive" >
    {session.scheduled_at ? (
    ) : (
    )} {session.start_hole !== 1 && (
    )}
    {mode === "confirmingDelete" && (deleteError ? (

    {deleteError}

    ) : (

    Er du sikker på at du vil slette denne økten? Dette kan ikke angres.

    ))}
    )}
  • ) } // --- Edit session form ------------------------------------------------------- function EditSessionForm({ session, course, courses, organizationId, onCourseImported, onSave, onCancel, }: { session: ApiSession course: ApiCourse | null courses: ApiCourse[] organizationId: string onCourseImported: (course: ApiCourse) => void onSave: (patch: Record) => Promise onCancel: () => void }) { const [name, setName] = useState(session.name ?? "") const [points, setPoints] = useState(String(session.points_per_match)) const [startHole, setStartHole] = useState(String(session.start_hole)) const [scheduledAt, setScheduledAt] = useState(session.scheduled_at?.slice(0, 16) ?? "") const [teeInterval, setTeeInterval] = useState( session.tee_interval_minutes ? String(session.tee_interval_minutes) : "", ) const [courseId, setCourseId] = useState(session.course_id) const [courseName, setCourseName] = useState(course?.name ?? "") const [submitting, setSubmitting] = useState(false) const hasTime = scheduledAt.trim() !== "" const uid = session.id async function handleSubmit(e: React.FormEvent) { e.preventDefault() const pointsValue = Number(points.replace(",", ".")) || session.points_per_match const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1)) const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined setSubmitting(true) await onSave({ name: name.trim() || null, points_per_match: pointsValue, start_hole: startHoleValue, scheduled_at: hasTime ? scheduledAt : null, tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null, course_id: courseId, }) setSubmitting(false) } return (
    Rediger økt {session.sequence} {session.name?.trim() || `Økt ${session.sequence}`}
    setName(e.target.value)} className="h-12 rounded-2xl text-base" />
    { setCourseId(id) setCourseName(name) }} />
    setPoints(e.target.value)} className="h-12 rounded-2xl text-base" />
    setStartHole(e.target.value)} className="h-12 rounded-2xl text-base" />
    setScheduledAt(e.target.value)} className="h-12 rounded-2xl text-base" />
    {hasTime && (
    setTeeInterval(e.target.value)} className="h-12 rounded-2xl text-base" />
    )}
    ) } // --- Bane-bytte i redigeringsskjemaet (kun eksisterende baner -- PATCH sin // course_id må være en ekte, allerede opprettet bane, ikke en "opprett // ny"-tekststreng slik opprett-skjemaet tillater) ------------------------- function EditCourseField({ organizationId, courses, currentCourseId, currentCourseName, onCourseImported, onChange, }: { organizationId: string courses: ApiCourse[] currentCourseId: string currentCourseName: string onCourseImported: (course: ApiCourse) => void onChange: (courseId: string, courseName: string) => void }) { const [open, setOpen] = useState(false) const [query, setQuery] = useState("") const [officialSearchOpen, setOfficialSearchOpen] = useState(false) if (!open) { return (
    ) } const q = query.trim().toLowerCase() const matches = courses.filter((c) => c.name.toLowerCase().includes(q)) function select(c: ApiCourse) { onChange(c.id, c.name) setOpen(false) } return (
    setQuery(e.target.value)} placeholder="Søk blant organisasjonens baner…" className="h-11 flex-1 rounded-xl text-base" />
      {matches.map((c) => (
    • ))} {matches.length === 0 && (
    • Ingen treff.
    • )}
    {!officialSearchOpen ? ( ) : ( setOfficialSearchOpen(false)} onImported={(newCourse) => { onCourseImported(newCourse) select(newCourse) setOfficialSearchOpen(false) }} /> )}
    ) } function MetaBadge({ children }: { children: React.ReactNode }) { return ( {children} ) } // --- Date coverage summary --------------------------------------------------- // Ingen sammendrag fantes tidligere for "har alle runder fått dato" -- måtte // leses manuelt per øktkort. All nødvendig data finnes allerede i sessions. function DateCoverageSummary({ total, withDate }: { total: number; withDate: number }) { const allSet = withDate === total return (
    {allSet ? (
    ) } // --- Empty state ----------------------------------------------------------- function EmptyState() { return (

    Ingen økter ennå

    Legg til den første økten nedenfor for å bygge programmet.

    ) } // --- Create session form ----------------------------------------------------- function CreateSessionCard({ nextSequence, courses, organizationId, onCourseImported, onCreate, }: { nextSequence: number courses: ApiCourse[] organizationId: string onCourseImported: (course: ApiCourse) => void onCreate: (input: { name?: string format: SessionFormat holeConfig: HoleConfig scoringMode: ScoringMode pointsPerMatch: number scheduledAt?: string teeIntervalMinutes?: number startHole: number courseId: string | null newCourseName: string allowanceOverride: Record | null shambleBestN: number | null }) => Promise }) { const [name, setName] = useState("") const [format, setFormat] = useState("foursome") const [shambleBestN, setShambleBestN] = useState(1) const [holeConfig, setHoleConfig] = useState("full_18") const [scoringMode, setScoringMode] = useState("stroke") const [points, setPoints] = useState("1") const [scheduledAt, setScheduledAt] = useState("") const [teeInterval, setTeeInterval] = useState("") const [startHole, setStartHole] = useState("1") const [submitting, setSubmitting] = useState(false) const [courseQuery, setCourseQuery] = useState("") const [selectedCourseId, setSelectedCourseId] = useState(null) const [officialSearchOpen, setOfficialSearchOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [useHandicap, setUseHandicap] = useState(true) const [useCourseHandicap, setUseCourseHandicap] = useState(true) const [useMatchplayHandicap, setUseMatchplayHandicap] = useState(true) const [hcpPercent, setHcpPercent] = useState("") const trimmedCourseQuery = courseQuery.trim() const courseMatches = useMemo(() => { if (!trimmedCourseQuery || selectedCourseId) return [] const q = trimmedCourseQuery.toLowerCase() return courses.filter((c) => c.name.toLowerCase().includes(q)) }, [courses, trimmedCourseQuery, selectedCourseId]) const exactCourseMatch = courses.some( (c) => c.name.toLowerCase() === trimmedCourseQuery.toLowerCase(), ) const showCreateCourse = trimmedCourseQuery.length >= 2 && !exactCourseMatch && !selectedCourseId const hasTime = scheduledAt.trim() !== "" const courseChosen = selectedCourseId !== null || (showCreateCourse && trimmedCourseQuery.length >= 2) const valid = courseChosen function buildAllowanceOverride(): Record | null { const percentValue = hcpPercent.trim() === "" ? undefined : Number(hcpPercent.replace(",", ".")) const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue) if (useHandicap && useCourseHandicap && useMatchplayHandicap && !hasPercent) { // Alt på standard -- ikke send noe, backend bruker allerede riktige // defaults når allowance_override er null (se app/handicap.py). return null } const override: Record = { use_handicap: useHandicap, use_course_handicap: useCourseHandicap, use_matchplay_handicap: useMatchplayHandicap, } if (hasPercent) { const fraction = percentValue! / 100 override.strategy = SIDE_IS_UNIT_FORMAT.has(format) ? { type: "combined", percentage: fraction } : { type: "per_player", percentage: fraction } } return override } async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid || submitting) return const pointsValue = Number(points.replace(",", ".")) || 1 const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1)) const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined setSubmitting(true) await onCreate({ name: name.trim() || undefined, format, holeConfig, scoringMode, pointsPerMatch: pointsValue, scheduledAt: hasTime ? scheduledAt : undefined, teeIntervalMinutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : undefined, startHole: startHoleValue, courseId: selectedCourseId, newCourseName: trimmedCourseQuery, allowanceOverride: buildAllowanceOverride(), shambleBestN: format === "shamble" ? shambleBestN : null, }) setSubmitting(false) setName("") setFormat("foursome") setShambleBestN(1) setHoleConfig("full_18") setScoringMode("stroke") setPoints("1") setScheduledAt("") setTeeInterval("") setStartHole("1") setCourseQuery("") setSelectedCourseId(null) setOfficialSearchOpen(false) setAdvancedOpen(false) setUseHandicap(true) setUseCourseHandicap(true) setUseMatchplayHandicap(true) setHcpPercent("") } return (
    Økt {nextSequence} Legg til økt
    setName(e.target.value)} className="h-12 rounded-2xl text-base" />
    {FORMAT_OPTIONS.map((opt) => ( setFormat(opt)}> {FORMAT_LABELS[opt]} ))}
    {format === "shamble" && (
    Beste N av laget teller per hull
    {[1, 2, 3, 4].map((n) => ( setShambleBestN(n)}> {n} ))}

    Kan ikke endres etter at økten er opprettet.

    )} {format === "money_ball" && (

    Nøyaktig 4 spillere per side -- rekkefølgen (hvem som er "money ball" på hvilket hull) tildeles når spillerne legges til matchen.

    )}
    {selectedCourseId ? (
    {courses.find((c) => c.id === selectedCourseId)?.name}
    ) : (
    setCourseQuery(e.target.value)} className="h-12 rounded-2xl text-base" /> {trimmedCourseQuery.length > 0 && (
      {courseMatches.map((c) => (
    • ))} {courseMatches.length === 0 && !showCreateCourse && (
    • Ingen treff.
    • )} {showCreateCourse && (
    • 0 ? "border-t border-border" : ""}>
    • )}
    )} {officialSearchOpen && ( setOfficialSearchOpen(false)} onImported={(course) => { onCourseImported(course) setSelectedCourseId(course.id) setOfficialSearchOpen(false) }} /> )}
    )}
    {HOLE_OPTIONS.map((opt) => ( setHoleConfig(opt)}> {HOLE_LABELS[opt]} ))}
    {SCORING_OPTIONS.map((opt) => ( setScoringMode(opt)}> {SCORING_LABELS[opt]} ))}
    setPoints(e.target.value)} className="h-12 rounded-2xl text-base" />
    setStartHole(e.target.value)} className="h-12 rounded-2xl text-base" />
    setScheduledAt(e.target.value)} className="h-12 rounded-2xl text-base" />
    {hasTime && (
    setTeeInterval(e.target.value)} className="h-12 rounded-2xl text-base" />
    )}
    {advancedOpen && (
    setHcpPercent(e.target.value)} className="h-11 rounded-xl text-base" />
    )}
    ) } // --- Offisiell bane fra teeoff (ADR-019) ------------------------------------ type ApiOfficialFacility = { slug: string name: string city: string | null county: string | null } type ApiOfficialCourseOption = { teeoff_course_id: number name: string is_main_course: boolean } function OfficialCourseSearch({ organizationId, onClose, onImported, }: { organizationId: string onClose: () => void onImported: (course: ApiCourse) => void }) { const [query, setQuery] = useState("") const [facilities, setFacilities] = useState(null) const [selectedFacility, setSelectedFacility] = useState(null) const [courseOptions, setCourseOptions] = useState(null) const [searching, setSearching] = useState(false) const [importing, setImporting] = useState(false) const [error, setError] = useState(null) async function runSearch() { setSearching(true) setError(null) try { const res = await fetch( `/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" }, ) if (!res.ok) throw new Error(`search: ${res.status}`) setFacilities(await res.json()) } catch { setError("Klarte ikke å søke i teeoff sine baner akkurat nå.") setFacilities([]) } finally { setSearching(false) } } async function pickFacility(facility: ApiOfficialFacility) { setSelectedFacility(facility) setError(null) setCourseOptions(null) try { const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, { credentials: "include", }) if (!res.ok) throw new Error(`facility detail: ${res.status}`) const detail: { courses: ApiOfficialCourseOption[] } = await res.json() setCourseOptions(detail.courses) } catch { setError("Klarte ikke å hente baner for dette anlegget.") setCourseOptions([]) } } async function importCourse(course: ApiOfficialCourseOption) { if (!selectedFacility) return setImporting(true) setError(null) try { const res = await fetch(`/orgs/${organizationId}/courses/official-import`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ facility_slug: selectedFacility.slug, teeoff_course_id: course.teeoff_course_id, }), }) if (!res.ok) { const body = await res.json().catch(() => null) const code = body?.detail?.code if (code === "EXTERNAL_DATA_INCOMPLETE") { setError("Denne banen mangler nok data i teeoff til å importeres ennå.") } else if (code === "DUPLICATE") { setError("Denne banen er allerede importert til organisasjonen.") } else { setError("Klarte ikke å importere banen. Prøv igjen.") } return } onImported(await res.json()) } catch { setError("Klarte ikke å importere banen. Prøv igjen.") } finally { setImporting(false) } } return (
    Hent bane fra teeoff
    {error &&

    {error}

    } {!selectedFacility ? ( <> {/* Bevisst IKKE et eget
    her -- denne komponenten rendres inne i CreateSessionCard sitt , og nestede -elementer er ugyldig HTML. Nettleseren slår da sammen de to skjemaene i den faktiske DOM-en, som gjorde at "Søk"-knappen i praksis submittet det YTRE økt-skjemaet i stedet -- en ekte native side-navigasjon som vasket bort ?org=...-parameteren fra URL-en (funnet ved en reell feilrapport fra bruker mot produksjon). */}
    setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() runSearch() } }} className="h-11 flex-1 rounded-xl text-base" />
    {facilities && (
      {facilities.map((f) => (
    • ))} {facilities.length === 0 && (
    • Ingen treff.
    • )}
    )} ) : ( <> {courseOptions === null ? (

    Laster baner…

    ) : (
      {courseOptions.map((c) => (
    • ))} {courseOptions.length === 0 && (
    • Ingen 18-hulls baner å importere hos dette anlegget ennå.
    • )}
    )} )}
    ) } // --- Small UI helpers ------------------------------------------------------ function SegmentButton({ selected, onClick, children, }: { selected: boolean onClick: () => void children: React.ReactNode }) { return ( ) } function ToggleRow({ label, checked, onCheckedChange, }: { label: string checked: boolean onCheckedChange: (checked: boolean) => void }) { return (
    {label}
    ) } // --- Helpers --------------------------------------------------------------- function formatPoints(points: number) { return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") } function formatDateTime(iso: string) { const date = new Date(iso) if (Number.isNaN(date.getTime())) return iso return date.toLocaleString("no-NO", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", }) }