Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
// Individuell rangering PER ØKT i en org-turnering (FEATURE_BACKLOG.md
|
|
|
|
|
|
// "Leaderboard for runder og turneringer", avklart 2026-07-28 -- omfang
|
|
|
|
|
|
// bevisst AVGRENSET til én økt, ikke summert på tvers av turneringen, se
|
|
|
|
|
|
// CLAUDE.md for begrunnelse). Ved siden av, ikke i stedet for, det
|
|
|
|
|
|
// eksisterende lag-poeng-leaderboardet (tournament-leaderboard.tsx) -- KUN
|
|
|
|
|
|
// meningsfullt for scoring_mode='stroke'-økter i format singles/fourball,
|
|
|
|
|
|
// backend avviser (400) ellers.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Gjenbruker samme rangering/mode-toggle/verdi-merke-mønster som
|
|
|
|
|
|
// round-leaderboard.tsx (brutto/netto/poeng, "form + farge"-språket, delt
|
|
|
|
|
|
// plassering "T-N") -- egen, enklere lokal versjon her siden denne
|
|
|
|
|
|
// leaderboardet ikke har noen hull-for-hull-data å utvide til (backend
|
|
|
|
|
|
// returnerer kun aggregerte tall per deltaker, ikke per hull).
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react"
|
|
|
|
|
|
import Link from "next/link"
|
2026-08-24 09:55:04 +02:00
|
|
|
|
import { ArrowLeft, Target, Trophy } from "lucide-react"
|
Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
|
|
|
|
|
type ApiEntry = {
|
|
|
|
|
|
participant_id: string
|
|
|
|
|
|
display_name: string
|
|
|
|
|
|
team_id: string
|
|
|
|
|
|
team_name: string
|
|
|
|
|
|
team_color: string | null
|
|
|
|
|
|
thru: number
|
|
|
|
|
|
gross_score: number | null
|
|
|
|
|
|
gross_to_par: number | null
|
|
|
|
|
|
net_score: number | null
|
|
|
|
|
|
net_to_par: number | null
|
|
|
|
|
|
total_points: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiLeaderboard = {
|
|
|
|
|
|
session_id: string
|
|
|
|
|
|
format: string
|
|
|
|
|
|
entries: ApiEntry[]
|
2026-08-24 09:55:04 +02:00
|
|
|
|
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- backend har
|
|
|
|
|
|
// allerede byttet net_score/net_to_par til hunt-semantikk når aktiv,
|
|
|
|
|
|
// ingen klientsidig utregning nødvendig her (ulikt round-leaderboard.tsx,
|
|
|
|
|
|
// som regner netto selv). Kun brukt til å vise en forklarende banner.
|
|
|
|
|
|
net_display_style: "standard" | "hunt"
|
Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const FORMAT_LABELS: Record<string, string> = {
|
|
|
|
|
|
singles: "Singles",
|
|
|
|
|
|
fourball: "Fourball",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Mode = "gross" | "net" | "points"
|
|
|
|
|
|
|
|
|
|
|
|
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
|
|
|
|
|
|
|
|
|
|
|
|
function valueFor(entry: ApiEntry, mode: Mode): number | null {
|
|
|
|
|
|
if (mode === "gross") return entry.gross_to_par
|
|
|
|
|
|
if (mode === "net") return entry.net_to_par
|
|
|
|
|
|
return entry.total_points
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
|
|
|
|
|
|
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
|
|
|
|
|
|
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
|
|
|
|
|
|
const direction = mode === "points" ? -1 : 1
|
|
|
|
|
|
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
|
|
|
|
|
|
|
|
|
|
|
|
const ranked: RankedEntry[] = []
|
|
|
|
|
|
let rank = 0
|
|
|
|
|
|
let prevValue: number | null = null
|
|
|
|
|
|
for (let i = 0; i < withValue.length; i++) {
|
|
|
|
|
|
const e = withValue[i]
|
|
|
|
|
|
const v = valueFor(e, mode) as number
|
|
|
|
|
|
if (v !== prevValue) {
|
|
|
|
|
|
rank = i + 1
|
|
|
|
|
|
prevValue = v
|
|
|
|
|
|
}
|
|
|
|
|
|
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
|
|
|
|
|
|
ranked.push({ ...e, rank, tied })
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const e of withoutValue) {
|
|
|
|
|
|
ranked.push({ ...e, rank: null, tied: false })
|
|
|
|
|
|
}
|
|
|
|
|
|
return ranked
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatToPar(value: number): string {
|
|
|
|
|
|
if (value === 0) return "E"
|
|
|
|
|
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
|
|
|
|
|
if (value === 0) {
|
|
|
|
|
|
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
const under = value < 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
|
|
|
|
|
|
under ? "rounded-full" : "rounded-[6px]",
|
|
|
|
|
|
emphasize
|
|
|
|
|
|
? under
|
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
|
: "bg-brand-orange text-brand-orange-foreground"
|
|
|
|
|
|
: under
|
|
|
|
|
|
? "border-2 border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatToPar(value)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
|
|
|
|
|
|
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{value}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
|
|
|
|
|
|
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
|
|
|
|
|
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : "–"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
|
|
|
|
|
const options: { value: Mode; label: string }[] = [
|
|
|
|
|
|
{ value: "gross", label: "Brutto" },
|
|
|
|
|
|
{ value: "net", label: "Netto" },
|
|
|
|
|
|
{ value: "points", label: "Poeng" },
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
|
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={mode === opt.value}
|
|
|
|
|
|
onClick={() => onChange(opt.value)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
|
|
|
|
|
|
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{opt.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SessionIndividualLeaderboard({
|
|
|
|
|
|
organizationId,
|
|
|
|
|
|
tournamentId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
tournamentName,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
tournamentId: string
|
|
|
|
|
|
sessionId: string
|
|
|
|
|
|
tournamentName: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [data, setData] = useState<ApiLeaderboard | null>(null)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
const [mode, setMode] = useState<Mode>("gross")
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/individual-leaderboard`, {
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.status === 400) {
|
|
|
|
|
|
if (!cancelled)
|
|
|
|
|
|
setError("Denne økten støtter ikke individuell rangering -- kun slagspill i format singles eller fourball.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (res.status === 404) {
|
|
|
|
|
|
if (!cancelled) setError("Økten finnes ikke.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å hente rangeringen. Prøv igjen om litt.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const parsed: ApiLeaderboard = await res.json()
|
|
|
|
|
|
if (!cancelled) setData(parsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
void load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [organizationId, sessionId])
|
|
|
|
|
|
|
|
|
|
|
|
const backHref = `/tournaments/${tournamentId}/sessions/${sessionId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
|
|
|
|
|
<p className="text-base font-medium text-destructive">{error}</p>
|
|
|
|
|
|
<Link href={backHref} className="text-base font-semibold text-primary underline underline-offset-2">
|
|
|
|
|
|
Tilbake til økten
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!data) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
|
|
|
|
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ranked = rankEntries(data.entries, mode)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-[100dvh] bg-background">
|
|
|
|
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
|
|
|
|
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={backHref}
|
|
|
|
|
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
|
|
|
|
|
Individuell rangering
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
|
|
|
|
|
|
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-24 09:55:04 +02:00
|
|
|
|
{data.net_display_style === "hunt" && (
|
|
|
|
|
|
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
|
|
|
|
|
|
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
|
|
|
|
|
|
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
|
|
|
|
|
|
Jakter Jesper: alle starter på minus spillehandicapet sitt -- lavest sluttsum vinner.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
{data.entries.length === 0 ? (
|
|
|
|
|
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
|
|
|
|
|
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
|
|
|
|
|
<ModeToggle mode={mode} onChange={setMode} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
|
|
|
|
|
{ranked.map((entry) => {
|
|
|
|
|
|
const value = valueFor(entry, mode)
|
|
|
|
|
|
const isLeader = entry.rank === 1
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
|
|
|
|
|
|
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="size-2.5 shrink-0 rounded-full"
|
|
|
|
|
|
style={{ backgroundColor: entry.team_color ?? "var(--muted-foreground)" }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
|
|
|
|
<span className="truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
|
|
|
|
|
<span className="truncate text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
{entry.team_name} · {entry.thru > 0 ? `${entry.thru} hull spilt` : "Ikke startet"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{isLeader && entry.rank === 1 && <Trophy aria-hidden="true" className="size-4 shrink-0 text-primary" />}
|
|
|
|
|
|
{value !== null ? (
|
|
|
|
|
|
<ValueMark mode={mode} value={value} emphasize={isLeader} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="shrink-0 text-sm font-semibold text-muted-foreground">–</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
|
|
|
|
|
|
rangering.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|