2026-07-18 17:47:33 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
import type React from "react"
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
|
|
import Link from "next/link"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
|
ChevronRight,
|
|
|
|
|
|
ChevronsUpDown,
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
RefreshCw,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
Trophy,
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
WifiOff,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
} from "lucide-react"
|
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
|
import { cn } from "@/lib/utils"
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
|
|
|
|
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py)
|
|
|
|
|
|
|
|
|
|
|
|
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
|
|
|
|
|
type ScoringMode = "stroke" | "hole_result"
|
|
|
|
|
|
type HoleConfig = "full_18" | "front_9" | "back_9"
|
|
|
|
|
|
|
|
|
|
|
|
const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"]
|
|
|
|
|
|
|
|
|
|
|
|
const FORMAT_LABELS: Record<Format, string> = {
|
|
|
|
|
|
foursome: "Foursome",
|
|
|
|
|
|
greensome: "Greensome",
|
|
|
|
|
|
scramble_2: "Scramble (2)",
|
|
|
|
|
|
scramble_4: "Scramble (4)",
|
|
|
|
|
|
fourball: "Fourball",
|
|
|
|
|
|
singles: "Singel",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiTeam = { id: string; name: string; color: string | null }
|
|
|
|
|
|
|
|
|
|
|
|
type ApiSession = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string | null
|
|
|
|
|
|
format: Format
|
|
|
|
|
|
hole_config: HoleConfig
|
|
|
|
|
|
course_id: string
|
|
|
|
|
|
scoring_mode: ScoringMode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiParticipant = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
team_side: "a" | "b"
|
|
|
|
|
|
team_roster_id: string
|
|
|
|
|
|
player_name: string
|
|
|
|
|
|
tee_id: string
|
|
|
|
|
|
tee_name: string
|
2026-07-29 12:44:51 +02:00
|
|
|
|
playing_handicap: number | null
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiMatch = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
sequence: number
|
|
|
|
|
|
team_a_id: string
|
|
|
|
|
|
team_b_id: string
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiHole = { hole_number: number; par: number; stroke_index: number }
|
|
|
|
|
|
|
|
|
|
|
|
type ApiHoleScore = {
|
|
|
|
|
|
hole_number: number
|
|
|
|
|
|
team_side: "a" | "b"
|
|
|
|
|
|
match_participant_id: string | null
|
|
|
|
|
|
gross_strokes: number
|
2026-07-29 22:20:50 +02:00
|
|
|
|
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
|
|
|
|
|
|
// funksjon som frittstående runder fikk 2026-07-25). Kun satt for
|
|
|
|
|
|
// delt-ball-rader (match_participant_id null).
|
|
|
|
|
|
selected_participant_id: string | null
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null }
|
|
|
|
|
|
|
|
|
|
|
|
type ApiScorecard = {
|
|
|
|
|
|
match_id: string
|
|
|
|
|
|
status_text: string | null
|
|
|
|
|
|
points_side_a: number | null
|
|
|
|
|
|
points_side_b: number | null
|
|
|
|
|
|
holes: { hole_number: number; result: "a" | "b" | "halved" }[]
|
|
|
|
|
|
stroke_entries: ApiHoleScore[] | null
|
|
|
|
|
|
hole_result_entries: ApiHoleResult[] | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Enhet som scores: én per SPILLER for individuelle formater (singles/
|
|
|
|
|
|
// fourball), én per SIDE for delt-ball-formater (foursome/greensome/
|
|
|
|
|
|
// scramble) -- se app/routers/scoring.py sin is_individual-sjekk.
|
2026-07-29 12:44:51 +02:00
|
|
|
|
type Unit = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
side: "a" | "b"
|
|
|
|
|
|
label: string
|
|
|
|
|
|
matchParticipantId: string | null
|
|
|
|
|
|
playingHandicap: number | null
|
|
|
|
|
|
}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
// Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per
|
|
|
|
|
|
// hull+side+enhet, samme identitet som backend bruker for upsert.
|
|
|
|
|
|
function strokeKey(holeNumber: number, unit: Unit): string {
|
|
|
|
|
|
return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
|
function playedHoleNumbers(holeConfig: HoleConfig): number[] {
|
|
|
|
|
|
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
|
|
|
|
|
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
|
|
|
|
|
return Array.from({ length: 18 }, (_, i) => i + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SessionScorecard({
|
|
|
|
|
|
organizationId,
|
|
|
|
|
|
tournamentId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
matchId,
|
|
|
|
|
|
tournamentName,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
tournamentId: string
|
|
|
|
|
|
sessionId: string
|
|
|
|
|
|
matchId: string
|
|
|
|
|
|
tournamentName: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [session, setSession] = useState<ApiSession | null>(null)
|
|
|
|
|
|
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
|
|
|
|
|
|
const [match, setMatch] = useState<ApiMatch | null>(null)
|
|
|
|
|
|
const [holes, setHoles] = useState<ApiHole[]>([])
|
|
|
|
|
|
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
const [currentHole, setCurrentHole] = useState<number | null>(null)
|
|
|
|
|
|
const [showSummary, setShowSummary] = useState(false)
|
2026-07-19 21:25:31 +02:00
|
|
|
|
const [showConcede, setShowConcede] = useState(false)
|
|
|
|
|
|
const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null)
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
// --- Offline scoreregistrering (ADR-028) -----------------------------
|
|
|
|
|
|
// isOnline styrer kun banner-teksten (navigator.onLine er allerede
|
|
|
|
|
|
// sjekket direkte der det faktisk betyr noe, i submitStroke/
|
|
|
|
|
|
// submitHoleResult) -- pendingStrokes/pendingResults er et lokalt
|
|
|
|
|
|
// overlay som viser verdier som er lagret i IndexedDB-køen, men ikke
|
|
|
|
|
|
// bekreftet mot serveren ennå.
|
|
|
|
|
|
const [isOnline, setIsOnline] = useState(true)
|
|
|
|
|
|
const [pendingCount, setPendingCount] = useState(0)
|
|
|
|
|
|
const [syncing, setSyncing] = useState(false)
|
|
|
|
|
|
const [pendingStrokes, setPendingStrokes] = useState<Map<string, number>>(new Map())
|
|
|
|
|
|
const [pendingResults, setPendingResults] = useState<Map<number, "a" | "b" | null>>(new Map())
|
|
|
|
|
|
|
|
|
|
|
|
async function flushPending() {
|
|
|
|
|
|
if (syncing) return
|
|
|
|
|
|
setSyncing(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const outcomes = await flushQueue(matchId)
|
|
|
|
|
|
if (outcomes.length === 0) return
|
|
|
|
|
|
setPendingStrokes((prev) => {
|
|
|
|
|
|
const next = new Map(prev)
|
|
|
|
|
|
for (const o of outcomes) {
|
|
|
|
|
|
if (o.entry.url.endsWith("/hole-scores")) {
|
|
|
|
|
|
const b = o.entry.body as {
|
|
|
|
|
|
hole_number: number
|
|
|
|
|
|
team_side: "a" | "b"
|
|
|
|
|
|
match_participant_id: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
next.delete(`${b.hole_number}:${b.team_side}:${b.match_participant_id ?? ""}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
setPendingResults((prev) => {
|
|
|
|
|
|
const next = new Map(prev)
|
|
|
|
|
|
for (const o of outcomes) {
|
|
|
|
|
|
if (o.entry.url.endsWith("/hole-results")) {
|
|
|
|
|
|
const b = o.entry.body as { hole_number: number }
|
|
|
|
|
|
next.delete(b.hole_number)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
const failed = outcomes.filter((o) => !o.ok)
|
|
|
|
|
|
if (failed.length > 0) {
|
|
|
|
|
|
setError(
|
|
|
|
|
|
`${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
setPendingCount(await queueCount(matchId))
|
|
|
|
|
|
await refetchScorecard()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSyncing(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setIsOnline(navigator.onLine)
|
|
|
|
|
|
function handleOnline() {
|
|
|
|
|
|
setIsOnline(true)
|
|
|
|
|
|
void flushPending()
|
|
|
|
|
|
}
|
|
|
|
|
|
function handleOffline() {
|
|
|
|
|
|
setIsOnline(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener("online", handleOnline)
|
|
|
|
|
|
window.addEventListener("offline", handleOffline)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener("online", handleOnline)
|
|
|
|
|
|
window.removeEventListener("offline", handleOffline)
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [matchId])
|
|
|
|
|
|
|
2026-07-29 22:20:50 +02:00
|
|
|
|
async function queueStroke(
|
|
|
|
|
|
holeNumber: number,
|
|
|
|
|
|
unit: Unit,
|
|
|
|
|
|
grossStrokes: number,
|
|
|
|
|
|
selectedParticipantId: string | null,
|
|
|
|
|
|
) {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
const url = `/orgs/${organizationId}/matches/${matchId}/hole-scores`
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
team_side: unit.side,
|
|
|
|
|
|
match_participant_id: unit.matchParticipantId,
|
|
|
|
|
|
hole_number: holeNumber,
|
|
|
|
|
|
gross_strokes: grossStrokes,
|
2026-07-29 22:20:50 +02:00
|
|
|
|
selected_participant_id: selectedParticipantId,
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
}
|
|
|
|
|
|
await enqueueWrite({ url, method: "POST", body, matchId })
|
|
|
|
|
|
setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes))
|
|
|
|
|
|
setPendingCount((c) => c + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
|
|
|
|
|
const url = `/orgs/${organizationId}/matches/${matchId}/hole-results`
|
|
|
|
|
|
const body = { hole_number: holeNumber, winning_side: winningSide }
|
|
|
|
|
|
await enqueueWrite({ url, method: "POST", body, matchId })
|
|
|
|
|
|
setPendingResults((prev) => new Map(prev).set(holeNumber, winningSide))
|
|
|
|
|
|
setPendingCount((c) => c + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
|
async function refetchScorecard() {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.ok) setScorecard(await res.json())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [sessionsRes, teamsRes, matchesRes] = await Promise.all([
|
|
|
|
|
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }),
|
|
|
|
|
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
|
|
|
|
|
|
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (!sessionsRes.ok || !teamsRes.ok || !matchesRes.ok) throw new Error("load failed")
|
|
|
|
|
|
const sessionsData: ApiSession[] = await sessionsRes.json()
|
|
|
|
|
|
const foundSession = sessionsData.find((s) => s.id === sessionId)
|
|
|
|
|
|
if (!foundSession) throw new Error("session not found")
|
|
|
|
|
|
const teamsData: ApiTeam[] = await teamsRes.json()
|
|
|
|
|
|
if (teamsData.length !== 2) throw new Error("expected 2 teams")
|
|
|
|
|
|
const matchesData: ApiMatch[] = await matchesRes.json()
|
|
|
|
|
|
const foundMatch = matchesData.find((m) => m.id === matchId)
|
|
|
|
|
|
if (!foundMatch) throw new Error("match not found")
|
|
|
|
|
|
|
|
|
|
|
|
const [holesRes, scorecardRes] = await Promise.all([
|
|
|
|
|
|
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/holes`, { credentials: "include" }),
|
|
|
|
|
|
fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include" }),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (!holesRes.ok || !scorecardRes.ok) throw new Error("load failed")
|
|
|
|
|
|
const holesData: ApiHole[] = await holesRes.json()
|
|
|
|
|
|
const scorecardData: ApiScorecard = await scorecardRes.json()
|
|
|
|
|
|
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
setSession(foundSession)
|
|
|
|
|
|
setTeams([teamsData[0], teamsData[1]])
|
|
|
|
|
|
setMatch(foundMatch)
|
|
|
|
|
|
setHoles(holesData)
|
|
|
|
|
|
setScorecard(scorecardData)
|
|
|
|
|
|
const played = playedHoleNumbers(foundSession.hole_config)
|
|
|
|
|
|
const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1]
|
|
|
|
|
|
setCurrentHole(nextUnregistered)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
|
|
|
|
|
|
const count = await queueCount(matchId).catch(() => 0)
|
|
|
|
|
|
if (!cancelled) setPendingCount(count)
|
|
|
|
|
|
if (count > 0 && navigator.onLine) void flushPending()
|
2026-07-18 17:47:33 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.")
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [organizationId, tournamentId, sessionId, matchId])
|
|
|
|
|
|
|
|
|
|
|
|
const units: Unit[] = useMemo(() => {
|
|
|
|
|
|
if (!session || !match || !teams) return []
|
|
|
|
|
|
const isIndividual = INDIVIDUAL_FORMATS.includes(session.format)
|
|
|
|
|
|
if (isIndividual) {
|
|
|
|
|
|
return match.participants.map((p) => ({
|
|
|
|
|
|
id: p.id,
|
|
|
|
|
|
side: p.team_side,
|
|
|
|
|
|
label: p.player_name,
|
|
|
|
|
|
matchParticipantId: p.id,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
playingHandicap: p.playing_handicap,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
// Delt-ball (foursome/greensome/scramble): enheten er selve SIDEN, men
|
|
|
|
|
|
// playing_handicap er den kombinerte side-verdien lagret på HVER av
|
|
|
|
|
|
// sidens deltakere (ADR-039) -- plukk den fra en av dem.
|
2026-07-18 17:47:33 +02:00
|
|
|
|
return [
|
2026-07-29 12:44:51 +02:00
|
|
|
|
{
|
|
|
|
|
|
id: "a",
|
|
|
|
|
|
side: "a" as const,
|
|
|
|
|
|
label: teams[0].name,
|
|
|
|
|
|
matchParticipantId: null,
|
|
|
|
|
|
playingHandicap: match.participants.find((p) => p.team_side === "a")?.playing_handicap ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "b",
|
|
|
|
|
|
side: "b" as const,
|
|
|
|
|
|
label: teams[1].name,
|
|
|
|
|
|
matchParticipantId: null,
|
|
|
|
|
|
playingHandicap: match.participants.find((p) => p.team_side === "b")?.playing_handicap ?? null,
|
|
|
|
|
|
},
|
2026-07-18 17:47:33 +02:00
|
|
|
|
]
|
|
|
|
|
|
}, [session, match, teams])
|
|
|
|
|
|
|
|
|
|
|
|
const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null
|
|
|
|
|
|
|
2026-07-29 22:20:50 +02:00
|
|
|
|
// Scramble/greensome: hvem sitt utslag som ble valgt for et allerede
|
|
|
|
|
|
// registrert hull (migrasjon 039) -- lest fra scorekortet, ikke lagret
|
|
|
|
|
|
// lokalt separat.
|
|
|
|
|
|
function selectedDriverFor(holeNumber: number, unit: Unit): string | null {
|
|
|
|
|
|
const entry = (scorecard?.stroke_entries ?? []).find(
|
|
|
|
|
|
(e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId,
|
|
|
|
|
|
)
|
|
|
|
|
|
return entry?.selected_participant_id ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// `selectedParticipantId` utelatt = behold gjeldende valg uendret (brukt
|
|
|
|
|
|
// av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt
|
|
|
|
|
|
// utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under).
|
|
|
|
|
|
async function submitStroke(
|
|
|
|
|
|
holeNumber: number,
|
|
|
|
|
|
unit: Unit,
|
|
|
|
|
|
grossStrokes: number,
|
|
|
|
|
|
selectedParticipantId?: string | null,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const resolvedSelection =
|
|
|
|
|
|
selectedParticipantId !== undefined ? selectedParticipantId : selectedDriverFor(holeNumber, unit)
|
2026-07-18 17:47:33 +02:00
|
|
|
|
setError(null)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
if (!navigator.onLine) {
|
2026-07-29 22:20:50 +02:00
|
|
|
|
await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let res: Response
|
2026-07-18 17:47:33 +02:00
|
|
|
|
try {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, {
|
2026-07-18 17:47:33 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
team_side: unit.side,
|
|
|
|
|
|
match_participant_id: unit.matchParticipantId,
|
|
|
|
|
|
hole_number: holeNumber,
|
|
|
|
|
|
gross_strokes: grossStrokes,
|
2026-07-29 22:20:50 +02:00
|
|
|
|
selected_participant_id: resolvedSelection,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
|
|
|
|
|
// for å vise en feil, se ADR-028.
|
2026-07-29 22:20:50 +02:00
|
|
|
|
await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) {
|
2026-07-18 17:47:33 +02:00
|
|
|
|
setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.")
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
return
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
await refetchScorecard()
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 21:25:31 +02:00
|
|
|
|
// Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller
|
|
|
|
|
|
// org-admin) får lov -- backend håndhever dette, UI-et viser bare begge
|
|
|
|
|
|
// knappene og lar en 403 melde seg som en vanlig feiltekst, samme mønster
|
|
|
|
|
|
// som resten av appen (ingen klientside-forhåndsfiltrering på kapteinstatus).
|
|
|
|
|
|
async function concede(teamId: string) {
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/concede`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ conceding_team_id: teamId }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const body = await res.json().catch(() => null)
|
|
|
|
|
|
throw new Error(body?.detail?.message ?? "Klarte ikke å registrere walkover.")
|
|
|
|
|
|
}
|
|
|
|
|
|
setConcedeTeamIndex(null)
|
|
|
|
|
|
setShowConcede(false)
|
|
|
|
|
|
await refetchScorecard()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e instanceof Error ? e.message : "Klarte ikke å registrere walkover.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
|
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
|
|
|
|
|
setError(null)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
if (!navigator.onLine) {
|
|
|
|
|
|
await queueHoleResult(holeNumber, winningSide)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let res: Response
|
2026-07-18 17:47:33 +02:00
|
|
|
|
try {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, {
|
2026-07-18 17:47:33 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }),
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
await queueHoleResult(holeNumber, winningSide)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!res.ok) {
|
2026-07-18 17:47:33 +02:00
|
|
|
|
setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.")
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
return
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
await refetchScorecard()
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
|
|
|
|
|
|
|
|
|
|
|
if (loading || !session || !teams || !match || !scorecard || currentHole === null) {
|
|
|
|
|
|
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 gap-3 px-5 py-4">
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={programHref}
|
|
|
|
|
|
aria-label="Tilbake til program"
|
|
|
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<div className="flex min-w-0 flex-col">
|
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Turnering
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
|
|
|
|
|
{tournamentName}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<main className="mx-auto flex w-full max-w-3xl flex-1 items-center justify-center px-5 py-6">
|
|
|
|
|
|
{error ? (
|
|
|
|
|
|
<p role="alert" className="text-sm font-medium text-destructive">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const played = playedHoleNumbers(session.hole_config)
|
|
|
|
|
|
const totalHoles = played.length
|
|
|
|
|
|
const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
const registeredHoleNumbers = new Set([
|
|
|
|
|
|
...scorecard.holes.map((h) => h.hole_number),
|
|
|
|
|
|
...Array.from(pendingStrokes.keys(), (k) => Number(k.split(":")[0])),
|
|
|
|
|
|
...Array.from(pendingResults.keys()),
|
|
|
|
|
|
])
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
|
|
|
|
|
const statusLabel = scorecard.status_text ?? "AS"
|
|
|
|
|
|
|
|
|
|
|
|
function strokeValue(holeNumber: number, unit: Unit): number | null {
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
const key = strokeKey(holeNumber, unit)
|
|
|
|
|
|
if (pendingStrokes.has(key)) return pendingStrokes.get(key)!
|
2026-07-18 17:47:33 +02:00
|
|
|
|
const entry = (scorecard!.stroke_entries ?? []).find(
|
|
|
|
|
|
(e) =>
|
|
|
|
|
|
e.hole_number === holeNumber &&
|
|
|
|
|
|
e.team_side === unit.side &&
|
|
|
|
|
|
e.match_participant_id === unit.matchParticipantId,
|
|
|
|
|
|
)
|
|
|
|
|
|
return entry ? entry.gross_strokes : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
const currentResultEntry = pendingResults.has(currentHole)
|
|
|
|
|
|
? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null }
|
|
|
|
|
|
: (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole)
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
|
|
|
|
|
function goPrev() {
|
|
|
|
|
|
setCurrentHole((h) => {
|
|
|
|
|
|
const idx = played.indexOf(h!)
|
|
|
|
|
|
return played[Math.max(0, idx - 1)]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
function goNext() {
|
|
|
|
|
|
setCurrentHole((h) => {
|
|
|
|
|
|
const idx = played.indexOf(h!)
|
|
|
|
|
|
return played[Math.min(played.length - 1, idx + 1)]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isIndividual = INDIVIDUAL_FORMATS.includes(session.format)
|
|
|
|
|
|
const isLast = played.indexOf(currentHole) >= played.length - 1
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
|
|
|
|
|
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur">
|
|
|
|
|
|
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={programHref}
|
|
|
|
|
|
aria-label="Tilbake til program"
|
|
|
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<div className="flex min-w-0 flex-col">
|
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Turnering
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
|
|
|
|
|
{tournamentName}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
|
|
|
|
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]} · Match {match.sequence}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border-t border-border bg-card/40">
|
|
|
|
|
|
<div className="mx-auto grid w-full max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-2 px-5 py-3 sm:gap-4">
|
|
|
|
|
|
<TeamTag name={teams[0].name} color={teams[0].color ?? "#64748b"} align="left" />
|
|
|
|
|
|
{decided ? (
|
|
|
|
|
|
<div className="flex flex-col items-center px-1">
|
|
|
|
|
|
<span className="text-lg font-extrabold tracking-tight text-primary">Avgjort</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex items-center gap-1.5 sm:gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={goPrev}
|
|
|
|
|
|
disabled={played.indexOf(currentHole) <= 0}
|
|
|
|
|
|
aria-label="Forrige hull"
|
|
|
|
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronLeft aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="flex min-w-[84px] flex-col items-center">
|
|
|
|
|
|
<span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
|
|
|
|
|
|
{statusLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={goNext}
|
|
|
|
|
|
disabled={isLast}
|
|
|
|
|
|
aria-label="Neste hull"
|
|
|
|
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<TeamTag name={teams[1].name} color={teams[1].color ?? "#64748b"} align="right" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
{!isOnline && (
|
|
|
|
|
|
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
|
|
|
|
|
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
|
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{pendingCount > 0 && (
|
|
|
|
|
|
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{isOnline && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void flushPending()}
|
|
|
|
|
|
disabled={syncing}
|
|
|
|
|
|
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
|
|
|
|
|
|
Synkroniser nå
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
{error && (
|
|
|
|
|
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{decided ? (
|
|
|
|
|
|
<DecidedView
|
|
|
|
|
|
teams={teams}
|
|
|
|
|
|
scorecard={scorecard}
|
|
|
|
|
|
holes={holes}
|
|
|
|
|
|
units={units}
|
|
|
|
|
|
scoringMode={session.scoring_mode}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
holeConfig={session.hole_config}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<nav aria-label="Velg hull" className="-mx-5 mb-4 overflow-x-auto px-5">
|
|
|
|
|
|
<div className="flex gap-2 pb-1">
|
|
|
|
|
|
{played.map((h) => {
|
|
|
|
|
|
const active = h === currentHole
|
|
|
|
|
|
const registered = registeredHoleNumbers.has(h)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={h}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setCurrentHole(h)}
|
|
|
|
|
|
aria-current={active ? "true" : undefined}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"relative flex size-11 shrink-0 flex-col items-center justify-center rounded-2xl border text-sm font-bold tabular-nums transition-colors",
|
|
|
|
|
|
active
|
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{h}
|
|
|
|
|
|
{registered && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full border-2 border-background",
|
|
|
|
|
|
active ? "bg-primary-foreground text-primary" : "bg-primary text-primary-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check className="size-2.5" strokeWidth={3} />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowSummary((v) => !v)}
|
|
|
|
|
|
aria-expanded={showSummary}
|
|
|
|
|
|
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">Vis full oversikt</span>
|
|
|
|
|
|
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{showSummary && (
|
|
|
|
|
|
<div className="border-t border-border">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<TournamentMatchGrid
|
|
|
|
|
|
teams={teams}
|
|
|
|
|
|
units={units}
|
|
|
|
|
|
holes={holes}
|
|
|
|
|
|
scorecard={scorecard}
|
|
|
|
|
|
scoringMode={session.scoring_mode}
|
|
|
|
|
|
holeConfig={session.hole_config}
|
|
|
|
|
|
/>
|
2026-07-29 22:20:50 +02:00
|
|
|
|
{!isIndividual && session.scoring_mode === "stroke" && match && (
|
|
|
|
|
|
<SelectedDriverSummary
|
|
|
|
|
|
teams={teams}
|
|
|
|
|
|
participants={match.participants}
|
|
|
|
|
|
strokeEntries={scorecard.stroke_entries ?? []}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-19 21:25:31 +02:00
|
|
|
|
<div className="mb-6 overflow-hidden rounded-2xl border border-border bg-card">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setShowConcede((v) => !v)
|
|
|
|
|
|
setConcedeTeamIndex(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-expanded={showConcede}
|
|
|
|
|
|
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">Gi opp matchen (walkover)</span>
|
|
|
|
|
|
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{showConcede && (
|
|
|
|
|
|
<div className="flex flex-col gap-3 border-t border-border p-4">
|
|
|
|
|
|
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
|
Kun kapteinen for laget som gir seg (eller en organisasjonsadministrator) kan
|
|
|
|
|
|
gjøre dette. Motstanderen får full poengsum for matchen, uansett hvor mange hull
|
|
|
|
|
|
som allerede er registrert.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{teams.map((team, i) =>
|
|
|
|
|
|
concedeTeamIndex === i ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={team.id}
|
|
|
|
|
|
className="flex flex-col gap-3 rounded-xl bg-destructive/5 p-3 sm:flex-row sm:items-center sm:justify-between"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm text-foreground text-pretty">
|
|
|
|
|
|
Er du sikker på at <span className="font-semibold">{team.name}</span> gir seg?
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex shrink-0 gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
className="rounded-xl"
|
|
|
|
|
|
onClick={() => concede(team.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Gi opp
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
className="rounded-xl"
|
|
|
|
|
|
onClick={() => setConcedeTeamIndex(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Avbryt
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={team.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="h-11 justify-start rounded-xl text-sm font-semibold"
|
|
|
|
|
|
onClick={() => setConcedeTeamIndex(i as 0 | 1)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span aria-hidden="true" className="mr-2 size-3 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
|
|
|
|
|
|
{team.name} gir seg
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
),
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
|
{session.scoring_mode === "stroke" ? (
|
|
|
|
|
|
<section aria-label={`Hull ${currentHole}`}>
|
|
|
|
|
|
<div className="mb-4 flex items-baseline gap-2">
|
|
|
|
|
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
|
|
|
|
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
{units.map((unit) => {
|
|
|
|
|
|
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
|
|
|
|
|
const value = strokeValue(currentHole, unit)
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
const isPending = pendingStrokes.has(strokeKey(currentHole, unit))
|
2026-07-18 17:47:33 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={unit.id}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"
|
2026-07-18 17:47:33 +02:00
|
|
|
|
style={{ borderLeftWidth: 6, borderLeftColor: teamColor }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
|
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: teamColor }} />
|
|
|
|
|
|
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
|
|
|
|
|
|
{unit.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{!isIndividual && (
|
|
|
|
|
|
<span className="text-xs font-medium text-muted-foreground">(delt ball)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
Update Todos
Diagnostisere HCP-bug mot ekte data (gjort)
Fiks 1: filtrer valgte spillere bort fra listen (blind draw)
Fiks 3: tallvelger for slagregistrering (scorekort)
Fiks 4: hardkode full_18-scope i handicap-utregning (bekreftet bug)
Punkt 2: migrasjon 014 + kode (courses.py, handicap.py, matches.py, tournaments.py, frontend)
Scratch-verifisere punkt 2 (fletting, import, validering, remap)
Skrive ADR-029 + oppdatere FEATURE_BACKLOG.md
Presentere full plan og be om bekreftelse (ekte migrasjon + deploy)
Alle fire punkter er nå bygget og grundig scratch-verifisert. Oppsummering før jeg ber om bekreftelse på å rulle ut mot ekte systemer:
1 — Spillerliste: Fikset. Valgte spillere filtreres nå bort i stedet for å bare vises gråtonet.
3 — Tallvelger: Fikset. Ny StrokePicker (1–9, med utvidbar "10+"→10–19).
4 — HCP-bug: Bekreftet ekte kodebug (ikke bare synlighet). Jeg leste ekte produksjonsdata for testøkten din: front_9 + foursome, og alle fire deltakerne hadde NULL i handicap-feltene. Årsak: handicap-utregningen lette etter en tee-rating scopet til øktens hole_config, men slike rader lages i praksis kun med full_18-omfang — så den fant aldri noe for en front_9-økt. Din beskrevne formel (kombinert hcp/2, laveste side til 0 mottatte slag, resten fordelt fra stroke index 1) var allerede riktig implementert — bugen var at den aldri kjørte. Fikset og verifisert: identisk bruttoscore på begge sider ga nå et ikke-delt resultat, som beviser at hcp faktisk brukes.
2 — Tee/kjønn: Du hadde helt rett i presiseringen din — dette var større enn jeg først trodde. Kjønn er nå flyttet fra selve utslaget til ratingen (ny migrasjon 014_tee_gender_to_rating.sql), tee-valget i blind draw er blitt helt automatisk (ingen kjønnsvelger lenger), og manglende kjønn/rating avvises tydelig i stedet for å gjettes. Fikk egen ADR-029. Verifisert i fem separate runder, inkludert mot ekte teeoff-data (Borregaard) og en gjenskaping av akkurat Tjøme-mønsteret.
Bifunn, ikke fikset: en liten, urelatert 500-krasj (stroke-registrering på en bane uten registrerte hull) — vil du at jeg tar den også, eller lar den ligge som egen sak?
Utrullingsplan — dette er første gang i denne runden noe rører ekte data:
Migrasjon 014_tee_gender_to_rating.sql mot ekte teecup_db — slår sammen Tjømes 8 tee-rader til 4 (bevarer alle ratinger, reparerer alle referanser; mekanisk identisk med det jeg nettopp verifiserte i scratch)
2026-07-20 08:53:53 +02:00
|
|
|
|
<StrokePicker
|
|
|
|
|
|
value={value}
|
|
|
|
|
|
par={par}
|
|
|
|
|
|
isPending={isPending}
|
|
|
|
|
|
onSelect={(n) => void submitStroke(currentHole, unit, n)}
|
|
|
|
|
|
/>
|
2026-07-29 22:20:50 +02:00
|
|
|
|
{/* Scramble/greensome: hvem sitt utslag ble valgt
|
|
|
|
|
|
(migrasjon 039, samme funksjon som frittstående
|
|
|
|
|
|
runder fikk 2026-07-25). Krever et registrert
|
|
|
|
|
|
slagtall å knytte valget til -- hole_score-raden
|
|
|
|
|
|
finnes ikke før da (ulikt round_hole, som alltid
|
|
|
|
|
|
forhåndsopprettes). Helt valgfritt, egen handling. */}
|
|
|
|
|
|
{!isIndividual && value !== null && match && (
|
|
|
|
|
|
<fieldset className="flex flex-col gap-2">
|
|
|
|
|
|
<legend className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Hvem sitt utslag ble brukt?{" "}
|
|
|
|
|
|
<span className="font-normal normal-case">(valgfritt)</span>
|
|
|
|
|
|
</legend>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{match.participants
|
|
|
|
|
|
.filter((p) => p.team_side === unit.side)
|
|
|
|
|
|
.map((p) => {
|
|
|
|
|
|
const active = selectedDriverFor(currentHole, unit) === p.id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={p.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void submitStroke(currentHole, unit, value, active ? null : p.id)}
|
|
|
|
|
|
aria-pressed={active}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors",
|
|
|
|
|
|
active
|
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{p.player_name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</fieldset>
|
|
|
|
|
|
)}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<section aria-label={`Hull ${currentHole}`}>
|
|
|
|
|
|
<div className="mb-4 flex items-baseline gap-2">
|
|
|
|
|
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
|
|
|
|
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: "a", label: `${teams[0].name} vant`, color: teams[0].color, side: "a" as const },
|
|
|
|
|
|
{ key: "h", label: "Delt", color: undefined, side: null },
|
|
|
|
|
|
{ key: "b", label: `${teams[1].name} vant`, color: teams[1].color, side: "b" as const },
|
|
|
|
|
|
] as const
|
|
|
|
|
|
).map((opt) => {
|
|
|
|
|
|
const active = currentResultEntry
|
|
|
|
|
|
? currentResultEntry.winning_side === opt.side
|
|
|
|
|
|
: false
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => submitHoleResult(currentHole, opt.side)}
|
|
|
|
|
|
aria-pressed={active}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-16 items-center justify-center gap-2 rounded-2xl border text-base font-bold tracking-tight transition-colors",
|
|
|
|
|
|
active
|
|
|
|
|
|
? opt.color
|
|
|
|
|
|
? "border-transparent text-primary-foreground"
|
|
|
|
|
|
: "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={active && opt.color ? { backgroundColor: opt.color } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{opt.color && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="size-3.5 rounded-full"
|
|
|
|
|
|
style={{ backgroundColor: active ? undefined : opt.color }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{opt.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
PWA er bygget og scratch/build-verifisert. Status:
Bygget:
Installerbar app: app/manifest.ts, appleWebApp-metadata for iOS, service worker (public/sw.js, håndskrevet — ingen next-pwa-avhengighet), public/offline.html.
Ikoner: enkelt grønt golf-flagg generert programmatisk (måtte omgå at verken PIL, rsvg-convert eller ImageMagick fantes i miljøet — løste det med et scratch npm-oppsett av sharp). Midlertidig, notert i FEATURE_BACKLOG.md for senere erstatning, slik du ba om. Erstattet samtidig den gamle apple-icon.png som faktisk var v0.app sin generiske plassholderlogo, ikke TeeCup-merkevare.
Full offline scoreregistrering: ny lib/offline-queue.ts (IndexedDB-kø) koblet inn i scorekort-skjermen. Slag/hull-resultat registrert offline vises umiddelbart som "Lagret lokalt · venter på synk", synkes automatisk når nettet er tilbake (pluss en manuell "Synkroniser nå"-knapp). Se ADR-028 for alle designvalgene (bl.a. hvorfor køen ligger i klientkoden og ikke i service workeren, og hvorfor jeg bevisst unngikk Background Sync API — iOS Safari støtter den ikke).
Viktig å være ærlig om: jeg har ingen nettleser-verktøy tilgjengelig i denne økten, så offline-flyten er verifisert med typesjekket produksjonsbuild + en kort container-boot (curl mot manifest/service worker/ikoner — alle svarer riktig), men ikke faktisk testet i en ekte nettleser (Chrome DevTools sin Offline-bryter, ordentlig "Legg til på hjemskjerm"). Anbefaler sterkt at du selv tester scorekort-siden med DevTools offline-modus før dere stoler på det i skarp bruk.
De fire UI-hullene du meldte inn er dokumentert i FEATURE_BACKLOG.md/CLAUDE.md med root cause (ikke bare notert som klager) — den mest presserende er /orgs/{id}/members, som er en reell rewrite/dynamisk-rute-kollisjon: siden er helt utilgjengelig i dag fordi Next.js sin /orgs/:path*-proxy-regel fanger kallet før selve siden noensinne nås.
Ingen migrasjon i denne runden — kun teecup_frontend trenger redeploy.
2026-07-19 23:51:08 +02:00
|
|
|
|
{pendingResults.has(currentHole) && (
|
|
|
|
|
|
<p className="mt-2 text-xs font-medium text-muted-foreground">
|
|
|
|
|
|
Lagret lokalt · venter på synk
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-07-18 17:47:33 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-6">
|
|
|
|
|
|
{!isLast ? (
|
|
|
|
|
|
<Button type="button" onClick={goNext} className="h-14 w-full rounded-2xl text-base font-bold shadow-sm">
|
|
|
|
|
|
Neste hull
|
|
|
|
|
|
<ChevronRight aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowSummary(true)}
|
|
|
|
|
|
className="h-14 w-full rounded-2xl text-base font-bold shadow-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check aria-hidden="true" className="size-5" />
|
|
|
|
|
|
Ferdig
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Small components --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) {
|
|
|
|
|
|
const right = align === "right"
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={cn("flex min-w-0 items-center gap-2", right ? "flex-row-reverse text-right" : "text-left")}>
|
|
|
|
|
|
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
|
|
|
|
|
<span className="truncate text-sm font-bold tracking-tight text-foreground">{name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Update Todos
Diagnostisere HCP-bug mot ekte data (gjort)
Fiks 1: filtrer valgte spillere bort fra listen (blind draw)
Fiks 3: tallvelger for slagregistrering (scorekort)
Fiks 4: hardkode full_18-scope i handicap-utregning (bekreftet bug)
Punkt 2: migrasjon 014 + kode (courses.py, handicap.py, matches.py, tournaments.py, frontend)
Scratch-verifisere punkt 2 (fletting, import, validering, remap)
Skrive ADR-029 + oppdatere FEATURE_BACKLOG.md
Presentere full plan og be om bekreftelse (ekte migrasjon + deploy)
Alle fire punkter er nå bygget og grundig scratch-verifisert. Oppsummering før jeg ber om bekreftelse på å rulle ut mot ekte systemer:
1 — Spillerliste: Fikset. Valgte spillere filtreres nå bort i stedet for å bare vises gråtonet.
3 — Tallvelger: Fikset. Ny StrokePicker (1–9, med utvidbar "10+"→10–19).
4 — HCP-bug: Bekreftet ekte kodebug (ikke bare synlighet). Jeg leste ekte produksjonsdata for testøkten din: front_9 + foursome, og alle fire deltakerne hadde NULL i handicap-feltene. Årsak: handicap-utregningen lette etter en tee-rating scopet til øktens hole_config, men slike rader lages i praksis kun med full_18-omfang — så den fant aldri noe for en front_9-økt. Din beskrevne formel (kombinert hcp/2, laveste side til 0 mottatte slag, resten fordelt fra stroke index 1) var allerede riktig implementert — bugen var at den aldri kjørte. Fikset og verifisert: identisk bruttoscore på begge sider ga nå et ikke-delt resultat, som beviser at hcp faktisk brukes.
2 — Tee/kjønn: Du hadde helt rett i presiseringen din — dette var større enn jeg først trodde. Kjønn er nå flyttet fra selve utslaget til ratingen (ny migrasjon 014_tee_gender_to_rating.sql), tee-valget i blind draw er blitt helt automatisk (ingen kjønnsvelger lenger), og manglende kjønn/rating avvises tydelig i stedet for å gjettes. Fikk egen ADR-029. Verifisert i fem separate runder, inkludert mot ekte teeoff-data (Borregaard) og en gjenskaping av akkurat Tjøme-mønsteret.
Bifunn, ikke fikset: en liten, urelatert 500-krasj (stroke-registrering på en bane uten registrerte hull) — vil du at jeg tar den også, eller lar den ligge som egen sak?
Utrullingsplan — dette er første gang i denne runden noe rører ekte data:
Migrasjon 014_tee_gender_to_rating.sql mot ekte teecup_db — slår sammen Tjømes 8 tee-rader til 4 (bevarer alle ratinger, reparerer alle referanser; mekanisk identisk med det jeg nettopp verifiserte i scratch)
2026-07-20 08:53:53 +02:00
|
|
|
|
// Tallvelger for slagregistrering: 1-9 direkte, pluss et "10 eller flere"-
|
|
|
|
|
|
// valg som åpner en ny rad med 10-19. Erstatter en tidligere pluss/minus-
|
|
|
|
|
|
// stepper -- raskere å treffe riktig tall banebruk (uten å måtte klikke seg
|
|
|
|
|
|
// opp/ned ett og ett slag).
|
|
|
|
|
|
function StrokePicker({
|
|
|
|
|
|
value,
|
|
|
|
|
|
par,
|
|
|
|
|
|
isPending,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: number | null
|
|
|
|
|
|
par: number
|
|
|
|
|
|
isPending: boolean
|
|
|
|
|
|
onSelect: (n: number) => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [showHigh, setShowHigh] = useState(value !== null && value >= 10)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setShowHigh(value !== null && value >= 10)
|
|
|
|
|
|
}, [value])
|
|
|
|
|
|
|
|
|
|
|
|
const numbers = showHigh
|
|
|
|
|
|
? Array.from({ length: 10 }, (_, i) => i + 10)
|
|
|
|
|
|
: Array.from({ length: 9 }, (_, i) => i + 1)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<div className="grid grid-cols-5 gap-2">
|
|
|
|
|
|
{numbers.map((n) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={n}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => onSelect(n)}
|
|
|
|
|
|
aria-pressed={value === n}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95",
|
|
|
|
|
|
value === n
|
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{n}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{!showHigh && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowHigh(true)}
|
|
|
|
|
|
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-card text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
10+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
|
{showHigh ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowHigh(false)}
|
|
|
|
|
|
className="text-xs font-semibold text-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
← Tilbake til 1–9
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
|
{value === null
|
|
|
|
|
|
? "Ikke registrert"
|
|
|
|
|
|
: isPending
|
|
|
|
|
|
? "Lagret lokalt · venter på synk"
|
|
|
|
|
|
: scoreToParLabel(value, par)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
// --- Match-scorekort: horisontalt rutenett med løpende stilling ------------
|
|
|
|
|
|
// Egen visuell utforming (ikke en kopi av noe referansebilde) -- samme
|
|
|
|
|
|
// informasjon som den tidligere vertikale hull-for-hull-listen (hvem vant
|
|
|
|
|
|
// hvilket hull, brutto der relevant), pluss en løpende "Stilling"-rad
|
|
|
|
|
|
// (AS/X UP/X&Y) regnet frem hull for hull i stedet for kun sluttresultatet.
|
|
|
|
|
|
// Bruker lagenes egne farger (samme konvensjon som resten av appens
|
|
|
|
|
|
// turnering-UI, ikke appens generiske primær/oransje-golfscore-språk --
|
|
|
|
|
|
// laget ER identiteten her, ikke over/under par).
|
|
|
|
|
|
|
|
|
|
|
|
type RunningState = { lead: number; label: string; tone: "a" | "b" | "neutral"; isClosed: boolean }
|
|
|
|
|
|
|
|
|
|
|
|
// Speiler handicap_engine.py sin compute_match_state()/describe() presist,
|
|
|
|
|
|
// regnet ett prefiks om gangen -- backend cacher i dag kun SLUTT-tilstanden.
|
|
|
|
|
|
function computeRunning(results: ("a" | "b" | "halved")[], totalHoles: number): RunningState[] {
|
|
|
|
|
|
const out: RunningState[] = []
|
|
|
|
|
|
let lead = 0
|
|
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
|
|
|
|
lead += results[i] === "a" ? 1 : results[i] === "b" ? -1 : 0
|
|
|
|
|
|
const holesPlayed = i + 1
|
|
|
|
|
|
const holesRemaining = totalHoles - holesPlayed
|
|
|
|
|
|
const isClosed = Math.abs(lead) > holesRemaining
|
|
|
|
|
|
const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
|
|
|
|
|
|
const margin = Math.abs(lead)
|
|
|
|
|
|
let label: string
|
|
|
|
|
|
if (isClosed) {
|
|
|
|
|
|
label = holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
|
|
|
|
|
|
} else if (lead === 0) {
|
|
|
|
|
|
label = "AS"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
label = isDormie ? `Dormie ${margin}` : `${margin} UP`
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push({ lead, label, tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", isClosed })
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function unitGross(scorecard: ApiScorecard, unit: Unit, holeNumber: number): number | null {
|
|
|
|
|
|
const entry = (scorecard.stroke_entries ?? []).find(
|
|
|
|
|
|
(e) =>
|
|
|
|
|
|
e.hole_number === holeNumber &&
|
|
|
|
|
|
e.team_side === unit.side &&
|
|
|
|
|
|
e.match_participant_id === unit.matchParticipantId,
|
|
|
|
|
|
)
|
|
|
|
|
|
return entry ? entry.gross_strokes : null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 16:26:31 +02:00
|
|
|
|
// Territorium-bar (2026-07-29, brukerens eget forslag: den ledende sidens
|
|
|
|
|
|
// sone strekker seg proporsjonalt forbi midtlinjen inn på motstanderens
|
|
|
|
|
|
// halvdel). Ulikt round-detail.tsx/round-scorecard.tsx sin primary/brand-
|
|
|
|
|
|
// orange-versjon bruker denne lagets FAKTISKE `team.color` (vilkårlig hex,
|
|
|
|
|
|
// samme som SegmentedBar i tournament-leaderboard.tsx) -- dominant sone får
|
|
|
|
|
|
// hvit tekst (samme kontrastvalg som SegmentedBar), lys sone en svak
|
|
|
|
|
|
// gjennomsiktig tone av samme farge med vanlig tema-tekst.
|
|
|
|
|
|
function leadZoneFraction(lead: number, totalHoles: number): number {
|
|
|
|
|
|
if (totalHoles <= 0) return 0.5
|
|
|
|
|
|
const fraction = 0.5 + (lead / totalHoles) * 0.5
|
|
|
|
|
|
return Math.min(0.82, Math.max(0.18, fraction))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
function IdentityBlock({
|
|
|
|
|
|
name,
|
|
|
|
|
|
hcp,
|
|
|
|
|
|
color,
|
|
|
|
|
|
side,
|
2026-07-29 16:26:31 +02:00
|
|
|
|
dominant,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
hcp: number | null
|
|
|
|
|
|
color: string
|
|
|
|
|
|
side: "a" | "b"
|
2026-07-29 16:26:31 +02:00
|
|
|
|
dominant: boolean
|
2026-07-29 12:44:51 +02:00
|
|
|
|
}) {
|
2026-07-29 16:26:31 +02:00
|
|
|
|
// Boksen STREKKER SEG (ingen items-start/items-end) -- ellers sizes den
|
|
|
|
|
|
// etter innholdets egen bredde, ikke sonens faktiske tildelte bredde, og
|
|
|
|
|
|
// kan visuelt lekke inn i midt-kolonnen på smale skjermer. Justering skjer
|
|
|
|
|
|
// med justify-start/justify-end + text-align inni en boks som alltid har
|
|
|
|
|
|
// full, korrekt bredde -- truncate virker da presist.
|
2026-07-29 12:44:51 +02:00
|
|
|
|
return (
|
2026-07-29 16:26:31 +02:00
|
|
|
|
<div className="flex h-full min-w-0 w-full flex-col justify-center gap-0.5 px-3">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex min-w-0 items-center gap-1.5",
|
|
|
|
|
|
side === "a" ? "justify-start" : "justify-end",
|
|
|
|
|
|
side === "b" && "flex-row-reverse",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{!dominant && <span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />}
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="min-w-0 truncate text-sm font-extrabold"
|
|
|
|
|
|
style={{ color: dominant ? "#fff" : undefined }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{name}
|
|
|
|
|
|
</span>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
</div>
|
2026-07-29 16:26:31 +02:00
|
|
|
|
{hcp !== null && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn("truncate text-xs font-bold tabular-nums text-muted-foreground", side === "a" ? "text-left" : "text-right")}
|
|
|
|
|
|
style={dominant ? { color: "rgba(255,255,255,0.75)" } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
HCP {hcp}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 22:20:50 +02:00
|
|
|
|
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 039, samme
|
|
|
|
|
|
// funksjon som frittstående runder fikk 2026-07-25, se round-stats.tsx sin
|
|
|
|
|
|
// tilsvarende SelectedDriverSummary). Ren klientside-telling fra allerede
|
|
|
|
|
|
// hentet scorecard-data -- vises kun de hullene som faktisk har et
|
|
|
|
|
|
// registrert valg, ingen antakelse om at ALLE hull må ha ett.
|
|
|
|
|
|
function SelectedDriverSummary({
|
|
|
|
|
|
teams,
|
|
|
|
|
|
participants,
|
|
|
|
|
|
strokeEntries,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
teams: [ApiTeam, ApiTeam]
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
strokeEntries: ApiHoleScore[]
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const rows = (["a", "b"] as const).map((side, idx) => {
|
|
|
|
|
|
const sideEntries = strokeEntries.filter((e) => e.team_side === side && e.match_participant_id === null)
|
|
|
|
|
|
const withSelection = sideEntries.filter((e) => e.selected_participant_id !== null)
|
|
|
|
|
|
const counts = new Map<string, number>()
|
|
|
|
|
|
for (const e of withSelection) {
|
|
|
|
|
|
const pid = e.selected_participant_id as string
|
|
|
|
|
|
counts.set(pid, (counts.get(pid) ?? 0) + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
const sideParticipants = participants.filter((p) => p.team_side === side)
|
|
|
|
|
|
return {
|
|
|
|
|
|
team: teams[idx],
|
|
|
|
|
|
playedCount: sideEntries.length,
|
|
|
|
|
|
registeredCount: withSelection.length,
|
|
|
|
|
|
entries: sideParticipants
|
|
|
|
|
|
.map((p) => ({ name: p.player_name, count: counts.get(p.id) ?? 0 }))
|
|
|
|
|
|
.sort((a, b) => b.count - a.count),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (rows.every((r) => r.registeredCount === 0)) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="border-t border-border p-4 text-sm text-muted-foreground">
|
|
|
|
|
|
Ingen har registrert hvem sitt utslag som ble brukt på noe hull ennå -- valgfritt felt i
|
|
|
|
|
|
scoreregistreringen.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-4 border-t border-border p-4">
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground">Utslag brukt</span>
|
|
|
|
|
|
{rows.map(({ team, playedCount, registeredCount, entries }) => (
|
|
|
|
|
|
<div key={team.id} className="flex flex-col gap-2">
|
|
|
|
|
|
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
|
|
|
|
|
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
|
|
|
|
|
|
{team.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
|
|
|
|
|
{entries.map((e) => (
|
|
|
|
|
|
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">{e.name}</span>
|
|
|
|
|
|
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
Registrert på {registeredCount} av {playedCount} spilte hull.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
function TournamentMatchGrid({
|
2026-07-18 17:47:33 +02:00
|
|
|
|
teams,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
units,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
holes,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
scorecard,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
scoringMode,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
holeConfig,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
teams: [ApiTeam, ApiTeam]
|
2026-07-29 12:44:51 +02:00
|
|
|
|
units: Unit[]
|
2026-07-18 17:47:33 +02:00
|
|
|
|
holes: ApiHole[]
|
2026-07-29 12:44:51 +02:00
|
|
|
|
scorecard: ApiScorecard
|
2026-07-18 17:47:33 +02:00
|
|
|
|
scoringMode: ScoringMode
|
2026-07-29 12:44:51 +02:00
|
|
|
|
holeConfig: HoleConfig
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
if (scorecard.holes.length === 0) {
|
|
|
|
|
|
return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
const decidedHoles = [...scorecard.holes].sort((a, b) => a.hole_number - b.hole_number)
|
|
|
|
|
|
const totalHoles = playedHoleNumbers(holeConfig).length
|
|
|
|
|
|
const running = computeRunning(
|
|
|
|
|
|
decidedHoles.map((h) => h.result),
|
|
|
|
|
|
totalHoles,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
)
|
2026-07-29 12:44:51 +02:00
|
|
|
|
const lastState = running.length > 0 ? running[running.length - 1] : null
|
2026-07-18 17:47:33 +02:00
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
function headerIdentity(side: "a" | "b"): { name: string; hcp: number | null; color: string } {
|
|
|
|
|
|
const team = side === "a" ? teams[0] : teams[1]
|
|
|
|
|
|
const color = team.color ?? "#64748b"
|
|
|
|
|
|
const sideUnits = units.filter((u) => u.side === side)
|
|
|
|
|
|
if (sideUnits.length === 1) return { name: sideUnits[0].label, hcp: sideUnits[0].playingHandicap, color }
|
|
|
|
|
|
return { name: team.name, hcp: null, color }
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
const identityA = headerIdentity("a")
|
|
|
|
|
|
const identityB = headerIdentity("b")
|
2026-07-29 16:26:31 +02:00
|
|
|
|
const lead = lastState?.lead ?? 0
|
|
|
|
|
|
const tone = lastState?.tone ?? "neutral"
|
|
|
|
|
|
const widthA = lastState ? leadZoneFraction(lead, totalHoles) * 100 : 50
|
|
|
|
|
|
const widthB = 100 - widthA
|
|
|
|
|
|
const zoneAStyle =
|
|
|
|
|
|
tone === "a"
|
|
|
|
|
|
? { backgroundColor: identityA.color }
|
|
|
|
|
|
: tone === "b"
|
|
|
|
|
|
? { backgroundColor: `color-mix(in srgb, ${identityA.color} 15%, transparent)` }
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
const zoneBStyle =
|
|
|
|
|
|
tone === "b"
|
|
|
|
|
|
? { backgroundColor: identityB.color }
|
|
|
|
|
|
: tone === "a"
|
|
|
|
|
|
? { backgroundColor: `color-mix(in srgb, ${identityB.color} 15%, transparent)` }
|
|
|
|
|
|
: undefined
|
2026-07-29 12:44:51 +02:00
|
|
|
|
|
2026-07-18 17:47:33 +02:00
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="flex flex-col gap-3 p-3 sm:p-4">
|
2026-07-29 16:26:31 +02:00
|
|
|
|
{/* CSS Grid, ikke absolutt overlegg (2026-07-29-fiks, se round-detail.tsx
|
|
|
|
|
|
sin FormatResultPanel for full begrunnelse) -- midt-kolonnen (status)
|
|
|
|
|
|
er `auto`-bredde, de to sonene `minmax(0, X fr)` -- reserverer alltid
|
|
|
|
|
|
nøyaktig plass til statusboksen, kan aldri overlappe et langt navn. */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="grid h-[76px] w-full overflow-hidden rounded-2xl sm:h-20"
|
|
|
|
|
|
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", tone === "neutral" && "bg-muted")}
|
|
|
|
|
|
style={zoneAStyle}
|
|
|
|
|
|
>
|
|
|
|
|
|
<IdentityBlock name={identityA.name} hcp={identityA.hcp} color={identityA.color} side="a" dominant={tone === "a"} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex h-full items-center justify-center px-2">
|
|
|
|
|
|
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
|
|
|
|
|
|
{lastState ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="text-xl font-extrabold leading-none tabular-nums sm:text-2xl"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
color:
|
|
|
|
|
|
lastState.tone === "a" ? identityA.color : lastState.tone === "b" ? identityB.color : undefined,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{lastState.label}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
</span>
|
2026-07-29 16:26:31 +02:00
|
|
|
|
{lastState.isClosed ? (
|
|
|
|
|
|
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Ferdig
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
|
|
|
|
|
|
{decidedHoles.length} hull spilt
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", tone === "neutral" && "bg-muted")}
|
|
|
|
|
|
style={zoneBStyle}
|
|
|
|
|
|
>
|
|
|
|
|
|
<IdentityBlock name={identityB.name} hcp={identityB.hcp} color={identityB.color} side="b" dominant={tone === "b"} />
|
2026-07-29 12:44:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="-mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0">
|
|
|
|
|
|
<table
|
|
|
|
|
|
className="w-full border-collapse text-sm"
|
|
|
|
|
|
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<colgroup>
|
|
|
|
|
|
<col className="w-16" />
|
|
|
|
|
|
{decidedHoles.map((h) => (
|
|
|
|
|
|
<col key={h.hole_number} className="w-10" />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</colgroup>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th
|
|
|
|
|
|
scope="col"
|
|
|
|
|
|
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
Hull
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{decidedHoles.map((h) => (
|
|
|
|
|
|
<th
|
|
|
|
|
|
key={h.hole_number}
|
|
|
|
|
|
scope="col"
|
|
|
|
|
|
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
|
|
|
|
|
|
>
|
|
|
|
|
|
{h.hole_number}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th
|
|
|
|
|
|
scope="row"
|
|
|
|
|
|
className="border border-border px-2 py-1.5 text-left text-xs font-bold text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
Par
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{decidedHoles.map((h) => (
|
|
|
|
|
|
<td
|
|
|
|
|
|
key={h.hole_number}
|
|
|
|
|
|
className="border border-border py-1.5 text-center text-xs font-semibold text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
{holes.find((x) => x.hole_number === h.hole_number)?.par ?? "–"}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{units.map((unit) => {
|
|
|
|
|
|
const color = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={unit.id}>
|
|
|
|
|
|
<th scope="row" className="border border-border px-2 py-1.5 text-left">
|
|
|
|
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className="size-2 shrink-0 rounded-full"
|
|
|
|
|
|
style={{ backgroundColor: color }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="min-w-0 truncate text-xs font-bold text-foreground">{unit.label}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{decidedHoles.map((h) => {
|
|
|
|
|
|
const won = h.result === unit.side
|
|
|
|
|
|
const gross = scoringMode === "stroke" ? unitGross(scorecard, unit, h.hole_number) : null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td key={h.hole_number} className="border border-border p-0.5 text-center">
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-label={won ? `${unit.label} vant hullet` : undefined}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
|
|
|
|
|
|
won ? "text-primary-foreground" : "text-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
style={won ? { backgroundColor: color } : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{scoringMode === "stroke" ? (gross ?? "–") : won ? "✓" : "–"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
<tr className="bg-muted/30">
|
|
|
|
|
|
<th
|
|
|
|
|
|
scope="row"
|
|
|
|
|
|
className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
Stilling
|
|
|
|
|
|
</th>
|
|
|
|
|
|
{running.map((s, i) => (
|
|
|
|
|
|
<td
|
|
|
|
|
|
key={decidedHoles[i].hole_number}
|
|
|
|
|
|
className="border border-border py-1.5 text-center text-xs font-extrabold tabular-nums"
|
|
|
|
|
|
style={{ color: s.tone === "a" ? identityA.color : s.tone === "b" ? identityB.color : undefined }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{s.label}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-18 17:47:33 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scoreToParLabel(value: number, par: number): string {
|
|
|
|
|
|
const diff = value - par
|
|
|
|
|
|
if (diff === 0) return "Par"
|
|
|
|
|
|
if (diff === -1) return "Birdie"
|
|
|
|
|
|
if (diff === -2) return "Eagle"
|
|
|
|
|
|
if (diff <= -3) return "Albatross"
|
|
|
|
|
|
if (diff === 1) return "Bogey"
|
|
|
|
|
|
if (diff === 2) return "Dobbel bogey"
|
|
|
|
|
|
return `+${diff}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DecidedView({
|
|
|
|
|
|
teams,
|
|
|
|
|
|
scorecard,
|
|
|
|
|
|
holes,
|
|
|
|
|
|
units,
|
|
|
|
|
|
scoringMode,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
holeConfig,
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
teams: [ApiTeam, ApiTeam]
|
|
|
|
|
|
scorecard: ApiScorecard
|
|
|
|
|
|
holes: ApiHole[]
|
|
|
|
|
|
units: Unit[]
|
|
|
|
|
|
scoringMode: ScoringMode
|
2026-07-29 12:44:51 +02:00
|
|
|
|
holeConfig: HoleConfig
|
2026-07-18 17:47:33 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-6">
|
|
|
|
|
|
<div className="flex items-center gap-4 rounded-3xl bg-primary px-5 py-5 text-primary-foreground shadow-sm">
|
|
|
|
|
|
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
|
|
|
|
|
|
<Trophy aria-hidden="true" className="size-7" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="text-lg font-extrabold tracking-tight">Matchen er avgjort</span>
|
|
|
|
|
|
<span className="text-sm text-primary-foreground/85 text-pretty">{scorecard.status_text ?? ""}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-3 items-center gap-2 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
|
|
|
|
|
|
<div className="flex flex-col items-start gap-1">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[0].color ?? "#64748b" }} />
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground text-pretty">{teams[0].name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-3xl font-extrabold tabular-nums text-foreground">
|
|
|
|
|
|
{formatPoints(scorecard.points_side_a ?? 0)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-center text-xs font-semibold uppercase tracking-wide text-muted-foreground">Poeng</div>
|
|
|
|
|
|
<div className="flex flex-col items-end gap-1">
|
|
|
|
|
|
<div className="flex flex-row-reverse items-center gap-2">
|
|
|
|
|
|
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[1].color ?? "#64748b" }} />
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground text-pretty">{teams[1].name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className="text-3xl font-extrabold tabular-nums text-foreground">
|
|
|
|
|
|
{formatPoints(scorecard.points_side_b ?? 0)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
|
|
|
|
|
|
<TournamentMatchGrid
|
|
|
|
|
|
teams={teams}
|
|
|
|
|
|
units={units}
|
|
|
|
|
|
holes={holes}
|
|
|
|
|
|
scorecard={scorecard}
|
|
|
|
|
|
scoringMode={scoringMode}
|
|
|
|
|
|
holeConfig={holeConfig}
|
|
|
|
|
|
/>
|
2026-07-18 17:47:33 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
|
|
|
|
|
|
Resultatet kan ikke endres etter at matchen er avgjort.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPoints(points: number) {
|
|
|
|
|
|
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
|
|
|
|
|
|
}
|