"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 { ArrowRight, Bell, 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 { InstallPrompt } from "@/components/install-prompt" 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 first_name: string | null 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 = { user_id: string | null 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[] my_holes_played: number my_total_score: number | null my_score_to_par: number | null my_net_score_to_par: number | null play_format: string my_match_status: string | null my_match_lead: number | null } type ApiHandicapPoint = { handicap_index: number; recorded_at: string } // ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele // GET /friends-responsen, som også bærer utgående forespørsler/kategorier). type ApiFriendsSummary = { friends: { first_name: string | null; last_name: string | null }[] incoming_requests: unknown[] } 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, viewerId: string | null, personalBestRoundId: string | null): Round { const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || 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.my_holes_played, totalScore: r.my_total_score ?? undefined, toPar: r.my_score_to_par ?? undefined, netToPar: r.my_net_score_to_par ?? undefined, playFormat: r.play_format, matchStatus: r.my_match_status, matchLead: r.my_match_lead, differential: me?.counts_for_handicap ? me.score_differential : null, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, } } // Laveste egne til-par blant minst to fullførte runder -- én eneste // fullført runde er ikke ennå meningsfullt en "rekord". function findPersonalBestRoundId(completedRounds: ApiRound[]): string | null { if (completedRounds.length < 2) return null let best: { id: string; toPar: number } | null = null for (const r of completedRounds) { if (r.my_score_to_par === null) continue if (!best || r.my_score_to_par < best.toPar) best = { id: r.id, toPar: r.my_score_to_par } } return best?.id ?? 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() 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(null) const [loadingMe, setLoadingMe] = useState(true) const [rounds, setRounds] = useState(null) const [combinedTournaments, setCombinedTournaments] = useState([]) const [hcpHistory, setHcpHistory] = useState([]) const [friendsSummary, setFriendsSummary] = useState(null) const [unreadNotifications, setUnreadNotifications] = useState(0) const [error, setError] = useState(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(() => {}) fetch("/friends", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) .catch(() => {}) fetch("/notifications/unread-count", { credentials: "include" }) .then((res) => (res.ok ? res.json() : { count: 0 })) .then((data: { count: number }) => setUnreadNotifications(data.count)) .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]) // Delt åpen/lukk-tilstand for hurtighandling-skjemaene (turnering/kode) -- // løftet til roten (2026-07-29, "alternativ designinstruks") slik at BÅDE // Hurtighandlinger-kortene OG tomtilstands-knappene i "Kommende // turneringer" kan åpne samme inline-skjema, i stedet for at sistnevnte // var uten href/onClick og ikke gjorde noe ved trykk. const [quickActionOpen, setQuickActionOpen] = useState<"tournament" | "code" | null>(null) const openQuickAction = useCallback((which: "tournament" | "code") => { setQuickActionOpen(which) document.getElementById("hurtighandlinger")?.scrollIntoView({ behavior: "smooth", block: "start" }) }, []) // "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, format: "team" | "individual") { 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, format_type: format }), }) 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 (
) } 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 personalBestRoundId = findPersonalBestRoundId(completedRounds) const avgToParSample = completedRounds .filter((r) => r.my_score_to_par !== null) .slice(0, 5) .map((r) => r.my_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() 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 (

God dag, {me.first_name ?? me.display_name}

Klar for en ny runde? Her er golfen din på ett sted.

{error && (

{error}

)}
toRound(r, me.id, personalBestRoundId))} loading={rounds === null} /> openQuickAction("tournament")} onJoinWithCode={() => openQuickAction("code")} />
) } // V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications, // IKKE /notifications (API-prefiks, samme kollisjonsklasse som // /rounds->/my-rounds og /friends->/my-friends). function NotificationBell({ unreadCount }: { unreadCount: number }) { const hasUnread = unreadCount > 0 const label = hasUnread ? `Varsler, ${unreadCount} uleste` : "Varsler, ingen uleste" const badgeText = unreadCount > 9 ? "9+" : String(unreadCount) return (