teecup/frontend/components/dashboard.tsx
Erol Haagenrud c8c4ad29b6 Dashbordet er bygget (zip 18 integrert): kun dashboard.tsx var reelt nytt fra V0, og jeg fant en genuin merge-situasjon i tournament-card.tsx — V0s nye "Arrangør"-merkelapp lagt til ved siden av den eksisterende offentlig/innlogget-lenkelogikken, ikke en revert. "Ny turnering" implementerer nå selve ADR-035-mekanismen (usynlig org-opprettelse ved innsending), "Statistikk"/"Spilte baner" er ren klientside-utledning fra data vi allerede har, og "Venner" viser en ærlig tom-tilstand siden ADR-036 ikke er bygget ennå. Typesjekket produksjonsbuild kompilerte rent.
Notert og bekreftet:

Medspillere skal kunne registrere score for hele flighten, ikke bare egen rad — oppdatert i ADR-036 (sletting av runden/bane-bytte forblir eier-eksklusivt).
To nye backlog-notater: flytte spillere mellom lag i turneringsoppsett, og flere flighter i én frittstående runde (sistnevnte har en reell modelleringsspenning mellom to retninger — ikke besluttet, bør trolig vente til ADR-036 fase 1 er bygget).
2026-07-25 07:51:32 +02:00

841 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Dashbord-redesign (ADR-035, 2026-07-25): organisasjon er ikke lenger det
// første/viktigste en bruker møter -- en bruker er først og fremst en
// GOLFSPILLER. Presentasjon fra V0 (zip 18), datalag skrevet om fra mock
// til ekte fetch. Organisasjon-opprettelse er nå USYNLIG/automatisk ("Ny
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A.
import type React from "react"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import {
Building2,
ChevronRight,
Flag,
KeyRound,
LogOut,
MapPin,
Plus,
TrendingDown,
TrendingUp,
Trophy,
UserCircle,
UserPlus,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Wordmark } from "@/components/wordmark"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type MyOrg = { organization_id: string; name: string; role: string }
// ADR-031: turneringer brukeren er SPILLER i (rostret på et lag), på tvers
// av organisasjoner, uavhengig av organisasjonsmedlemskap.
type MyTournament = {
organization_id: string
organization_name: string
tournament_id: string
tournament_name: string
status: TournamentStatus
team_id: string
team_name: string
team_color: string | null
next_session_at: string | null
my_session_id: string | null
my_match_id: string | null
}
type Me = {
id: string
email: string
display_name: string
preferred_locale: string
profile_complete: boolean
handicap_index: number | null
organizations: MyOrg[]
my_tournaments: MyTournament[]
}
type ApiTournament = {
id: string
name: string
status: TournamentStatus
start_date: string | null
end_date: string | null
}
type ApiRoundParticipant = { is_owner: boolean; counts_for_handicap: boolean; score_differential: number | null }
type ApiRound = {
id: string
name: string | null
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
holes_planned: number
completed_at: string | null
participants: ApiRoundParticipant[]
owner_holes_played: number
owner_total_score: number | null
owner_score_to_par: number | null
}
type ApiHandicapPoint = { handicap_index: number; recorded_at: string }
function toTournament(t: ApiTournament): Tournament {
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
}
function toRound(r: ApiRound): Round {
const owner = r.participants.find((p) => p.is_owner)
return {
id: r.id,
name: r.name,
courseName: r.course_name_snapshot,
status: r.completed_at ? "completed" : "active",
teeName: r.tee_name_snapshot,
holes: r.holes_planned === 9 ? 9 : 18,
date: r.played_at,
playerCount: r.participants.length,
holesPlayed: r.owner_holes_played,
totalScore: r.owner_total_score ?? undefined,
toPar: r.owner_score_to_par ?? undefined,
differential: owner?.counts_for_handicap ? owner.score_differential : null,
}
}
// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------
type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null }
function combineTournaments(myTournaments: MyTournament[], organizerLists: { orgId: string; tournaments: ApiTournament[] }[]): CombinedTournament[] {
const byId = new Map<string, CombinedTournament>()
for (const t of myTournaments) {
byId.set(t.tournament_id, {
tournament: { id: t.tournament_id, name: t.tournament_name, status: t.status, startDate: t.next_session_at ?? undefined },
organizer: false,
orgId: t.organization_id,
sortKey: t.next_session_at,
})
}
for (const { orgId, tournaments } of organizerLists) {
for (const t of tournaments) {
const existing = byId.get(t.id)
byId.set(t.id, {
tournament: existing?.tournament ?? toTournament(t),
organizer: true,
orgId: existing?.orgId ?? orgId,
sortKey: existing?.sortKey ?? t.start_date,
})
}
}
// Kun "kommende" -- fullførte/arkiverte turneringer skal ikke fylle opp
// en "kommende"-liste, uansett dato.
const upcoming = [...byId.values()].filter((c) => c.tournament.status === "draft" || c.tournament.status === "active")
upcoming.sort((a, b) => {
if (a.sortKey && b.sortKey) return a.sortKey < b.sortKey ? -1 : 1
if (a.sortKey) return -1
if (b.sortKey) return 1
return a.tournament.name.localeCompare(b.tournament.name)
})
return upcoming
}
// --- Formattering ------------------------------------------------------------
function formatSigned(value: number, decimals = 1): string {
if (value === 0) return decimals === 0 ? "0" : `0,${"0".repeat(decimals)}`
const abs = Math.abs(value).toFixed(decimals).replace(".", ",")
return value > 0 ? `+${abs}` : `${abs}`
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" })
function formatDate(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return dateFormatter.format(parsed)
}
// --- Root component ----------------------------------------------------------
export function Dashboard() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(null)
const [loadingMe, setLoadingMe] = useState(true)
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
const [error, setError] = useState<string | null>(null)
const loadMe = useCallback(async () => {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (!res.ok) {
router.replace("/")
return
}
const data: Me = await res.json()
if (!data.profile_complete) {
router.replace("/account")
return
}
setMe(data)
} catch {
router.replace("/")
} finally {
setLoadingMe(false)
}
}, [router])
useEffect(() => {
void loadMe()
}, [loadMe])
useEffect(() => {
fetch("/rounds", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiRound[]) => setRounds(data))
.catch(() => setRounds([]))
fetch("/auth/profile/handicap-history", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiHandicapPoint[]) => setHcpHistory(data))
.catch(() => {})
}, [])
useEffect(() => {
if (!me) return
let cancelled = false
async function loadOrganizerTournaments() {
try {
const lists = await Promise.all(
me!.organizations.map(async (o) => {
const res = await fetch(`/orgs/${o.organization_id}/tournaments`, { credentials: "include" })
const tournaments: ApiTournament[] = res.ok ? await res.json() : []
return { orgId: o.organization_id, tournaments }
}),
)
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, lists))
} catch {
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, []))
}
}
void loadOrganizerTournaments()
return () => {
cancelled = true
}
}, [me])
// "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) --
// ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet.
// Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles
// FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en
// foreldreløs tom org hvis brukeren avbryter).
async function handleCreateTournament(name: string, chosenOrgId: string | null) {
if (!me) return
setError(null)
try {
let orgId = chosenOrgId ?? (me.organizations.length === 1 ? me.organizations[0].organization_id : null)
if (!orgId) {
const orgName = me.display_name ? `${me.display_name}s turneringer` : "Mine turneringer"
const orgRes = await fetch("/orgs", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: orgName }),
})
if (!orgRes.ok) throw new Error("orgs")
const org: { id: string; name: string; role: string } = await orgRes.json()
orgId = org.id
setMe((prev) => (prev ? { ...prev, organizations: [...prev.organizations, { organization_id: org.id, name: org.name, role: org.role }] } : prev))
}
const res = await fetch(`/orgs/${orgId}/tournaments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error("create tournament")
const created: ApiTournament = await res.json()
router.push(`/tournaments/${created.id}?org=${orgId}&name=${encodeURIComponent(created.name)}`)
} catch {
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
}
}
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
}
}
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
if (!me) return null // router.replace("/") er allerede utløst
const rows = rounds ?? []
const activeRounds = rows.filter((r) => !r.completed_at)
const completedRounds = rows.filter((r) => r.completed_at)
const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
const avgToParSample = completedRounds
.filter((r) => r.owner_score_to_par !== null)
.slice(0, 5)
.map((r) => r.owner_score_to_par as number)
const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
const hcpValues = hcpHistory.map((h) => h.handicap_index)
const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null
const hcpNow = me.handicap_index !== null ? me.handicap_index.toFixed(1).replace(".", ",") : "—"
const coursesMap = new Map<string, { name: string; visits: number; lastPlayed: string }>()
for (const r of rows) {
const existing = coursesMap.get(r.course_name_snapshot)
if (existing) {
existing.visits += 1
if (r.played_at > existing.lastPlayed) existing.lastPlayed = r.played_at
} else {
coursesMap.set(r.course_name_snapshot, { name: r.course_name_snapshot, visits: 1, lastPlayed: r.played_at })
}
}
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
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 justify-between gap-4 px-5 py-4">
<Wordmark compact />
<div className="flex items-center gap-1">
<Link
href="/account"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<UserCircle aria-hidden="true" className="size-4" />
Konto
</Link>
<button
type="button"
onClick={handleLogout}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-16 pt-6 sm:pt-8">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
God dag, {me.display_name}
</h1>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
Klar for en ny runde? Her er golfen din ett sted.
</p>
</div>
{error && (
<p role="alert" className="mt-4 text-sm font-medium text-destructive">
{error}
</p>
)}
<div className="mt-6 flex flex-col gap-8">
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
<UpcomingRounds rounds={roundsForCards.map(toRound)} loading={rounds === null} />
<UpcomingTournaments entries={combinedTournaments} />
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
<PlayedCourses courses={playedCourses} />
<FriendsSection />
<OrganizationsFooter organizations={me.organizations} />
</div>
</main>
</div>
)
}
// --- 1. Hurtighandlinger -----------------------------------------------------
function QuickActions({
organizations,
onCreateTournament,
}: {
organizations: MyOrg[]
onCreateTournament: (name: string, orgId: string | null) => Promise<void>
}) {
const [open, setOpen] = useState<"tournament" | "code" | null>(null)
return (
<section aria-label="Hurtighandlinger" className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => setOpen(open === "tournament" ? null : "tournament")} />
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => setOpen(open === "code" ? null : "code")} />
</div>
{open === "tournament" && (
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => setOpen(null)} />
)}
{open === "code" && <JoinByCodeInline onClose={() => setOpen(null)} />}
</section>
)
}
function QuickAction({
icon: Icon,
label,
href,
onClick,
}: {
icon: typeof Flag
label: string
href?: string
onClick?: () => void
}) {
const className =
"flex min-h-[108px] flex-1 flex-col items-center justify-center gap-2.5 rounded-2xl border border-border bg-card p-4 text-center shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
const inner = (
<>
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Icon aria-hidden="true" className="size-6" />
</span>
<span className="text-sm font-bold leading-tight text-foreground text-balance">{label}</span>
</>
)
if (href) {
return (
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className}>
{inner}
</button>
)
}
function NewTournamentInline({
organizations,
onCreate,
onClose,
}: {
organizations: MyOrg[]
onCreate: (name: string, orgId: string | null) => Promise<void>
onClose: () => void
}) {
const [name, setName] = useState("")
const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "")
const [submitting, setSubmitting] = useState(false)
const valid = name.trim().length >= 2
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || submitting) return
setSubmitting(true)
await onCreate(name.trim(), organizations.length > 1 ? orgId : null)
setSubmitting(false)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-tournament-name" className="text-sm font-semibold">
Navn turnering
</Label>
<Input
id="new-tournament-name"
autoFocus
placeholder="F.eks. Sommercup 2026"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
{organizations.length > 1 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-tournament-org" className="text-sm font-semibold">
Organisasjon
</Label>
<select
id="new-tournament-org"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none sm:w-48"
>
{organizations.map((o) => (
<option key={o.organization_id} value={o.organization_id}>
{o.name}
</option>
))}
</select>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={!valid || submitting} className="h-11 rounded-xl font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
</form>
)
}
function JoinByCodeInline({ onClose }: { onClose: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = code.trim()
if (!trimmed || loading) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
return
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch {
setError("Klarte ikke å slå opp koden. Prøv igjen.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="join-code-inline" className="text-sm font-semibold">
Invitasjonskode
</Label>
<Input
id="join-code-inline"
autoFocus
placeholder="F.eks. 7K2M9P"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="h-11 rounded-xl uppercase tracking-widest"
/>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={!code.trim() || loading} className="h-11 rounded-xl font-bold">
{loading ? "Søker…" : "Bli med"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
</form>
)
}
// --- Delt seksjon-"chrome" ----------------------------------------------------
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
{action}
</div>
)
}
function SeeAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-primary transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{label}
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
)
}
function EmptyState({
icon: Icon,
title,
description,
children,
}: {
icon: typeof Flag
title: string
description: string
children?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border bg-card/50 px-6 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
<Icon aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-base font-bold text-foreground text-balance">{title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{description}</p>
</div>
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
</div>
)
}
function ShortcutButton({
icon: Icon,
label,
href,
variant = "primary",
}: {
icon: typeof Flag
label: string
href?: string
variant?: "primary" | "outline"
}) {
const className = cn(
"inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
)
const inner = (
<>
<Icon aria-hidden="true" className="size-4" />
{label}
</>
)
if (href) {
return (
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" className={className}>
{inner}
</button>
)
}
// --- 2. Kommende runder --------------------------------------------------------
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
const visible = rounds.slice(0, 3)
return (
<section aria-label="Kommende runder">
<SectionHeader title="Kommende runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
{loading ? (
<div className="flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
) : visible.length > 0 ? (
<div className="flex flex-col gap-3">
{visible.map((round) => (
<RoundCard key={round.id} round={round} />
))}
</div>
) : (
<EmptyState icon={Flag} title="Ingen runder på gang" description="Start en runde når du er på banen, så dukker den opp her mens du spiller.">
<ShortcutButton icon={Plus} label="Ny runde" href="/my-rounds/new" />
</EmptyState>
)}
</section>
)
}
// --- 3. Kommende turneringer -----------------------------------------------
function UpcomingTournaments({ entries }: { entries: CombinedTournament[] }) {
return (
<section aria-label="Kommende turneringer">
<SectionHeader title="Kommende turneringer" />
{entries.length > 0 ? (
<div className="flex flex-col gap-3">
{entries.map((e) => (
<TournamentCard key={e.tournament.id} tournament={e.tournament} orgId={e.orgId} organizer={e.organizer} />
))}
</div>
) : (
<EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen.">
<ShortcutButton icon={Trophy} label="Ny turnering" />
<ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" />
</EmptyState>
)}
</section>
)
}
// --- 4. Statistikk ---------------------------------------------------------
function Sparkline({ values }: { values: number[] }) {
if (values.length < 2) return null
const max = Math.max(...values)
const min = Math.min(...values)
const range = max - min || 1
return (
<div aria-hidden="true" className="flex h-5 items-end gap-0.5">
{values.map((v, i) => {
const height = 30 + ((v - min) / range) * 70
return <div key={i} className="w-1 rounded-full bg-primary/40" style={{ height: `${height}%` }} />
})}
</div>
)
}
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
{children}
</div>
)
}
function StatsSection({
roundsCount,
hcpNow,
hcpTrend,
hcpHistory,
avgToPar,
}: {
roundsCount: number
hcpNow: string
hcpTrend: number | null
hcpHistory: number[]
avgToPar: string
}) {
const TrendIcon = hcpTrend !== null && hcpTrend < 0 ? TrendingDown : TrendingUp
return (
<section aria-label="Statistikk">
<SectionHeader title="Statistikk" />
<div className="grid grid-cols-3 gap-3">
<StatTile label="Runder" value={String(roundsCount)} />
<StatTile label="HCP nå" value={hcpNow}>
{hcpTrend !== null ? (
<div className="flex items-center justify-between gap-1">
<span className="inline-flex items-center gap-1 text-xs font-bold text-foreground">
<TrendIcon aria-hidden="true" className="size-4 text-primary" />
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
</span>
<Sparkline values={hcpHistory} />
</div>
) : (
<span className="text-xs font-medium text-muted-foreground">Ingen data</span>
)}
</StatTile>
<StatTile label="Snitt til par" value={avgToPar} />
</div>
</section>
)
}
// --- 5. Spilte baner ---------------------------------------------------------
function PlayedCourses({ courses }: { courses: { name: string; visits: number; lastPlayed: string }[] }) {
return (
<section aria-label="Spilte baner">
<SectionHeader title="Spilte baner" />
{courses.length > 0 ? (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{courses.map((course) => {
const roundLabel = course.visits === 1 ? "runde" : "runder"
return (
<li key={course.name} className="flex min-h-[60px] items-center gap-3 px-4 py-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
<MapPin aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{course.name}</span>
<span className="truncate text-sm text-muted-foreground">
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</span>
</span>
</li>
)
})}
</ul>
) : (
<EmptyState icon={MapPin} title="Ingen baner registrert" description="Banene du spiller dukker opp her, med antall besøk og når du sist spilte." />
)}
</section>
)
}
// --- 6. Venner ---------------------------------------------------------------
// Vennekonseptet (ADR-036) er DESIGNET, ikke bygget ennå -- ingen backend å
// hente fra. Seksjonen vises som en ærlig tom-tilstand (matcher V0s design),
// men "Søk etter venner"-knappen er bevisst uten href/onClick (inert) til
// fase 1 av ADR-036 er bygget -- ingen /friends-side finnes å lenke til ennå.
function FriendsSection() {
return (
<section aria-label="Venner">
<SectionHeader title="Venner" />
<EmptyState icon={UserPlus} title="Finn vennene dine" description="Søk opp folk du spiller med, så kan dere følge hverandres runder og resultater.">
<ShortcutButton icon={UserPlus} label="Søk etter venner" />
</EmptyState>
</section>
)
}
// --- 7. Organisasjoner (bevisst nedtonet) -----------------------------------
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
const router = useRouter()
if (organizations.length === 0) return null
return (
<div className="flex justify-center pt-2">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Building2 aria-hidden="true" className="size-4" />
Dine organisasjoner ({organizations.length})
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64 rounded-2xl p-1.5">
{organizations.map((o) => (
<DropdownMenuItem
key={o.organization_id}
onClick={() => router.push(`/organizations/${o.organization_id}/members?name=${encodeURIComponent(o.name)}`)}
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{o.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}