Ekte i18n (next-intl) + hjelpeside/FAQ + Spillere-tabellen slått videre sammen (ADR-094)

Grunnmur for oversettelse (cookie-basert, ingen URL-endring), ny
/hjelp-side med oversikt+FAQ på norsk og engelsk, og Deltakere+
Rundedeltakelse slått sammen til én bred tabell i Spillere-steget.
Resten av appens ~2200 strenger og 479 backend-feilkoder er bevisst
utsatt til senere, avgrensede runder -- se FEATURE_BACKLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-19 07:54:07 +02:00
parent 59080731f4
commit 5355aba75d
22 changed files with 1640 additions and 673 deletions

View file

@ -9503,6 +9503,108 @@ denne runden -- vurdert tilstrekkelig dekket av backend-pytestene
compose build teecup_api teecup_frontend && up -d` for begge, rene compose build teecup_api teecup_frontend && up -d` for begge, rene
logger, 200 OK. logger, 200 OK.
## ADR-094: Ekte i18n-infrastruktur (next-intl) + hjelpeside/FAQ + Spillere-tabellen slått videre sammen (2026-08-19)
Bruker ba om en veiledning (FAQ + oversiktsside) for turneringsflyten,
presisert med "alt skal være oversettbart". Appen hadde på det
tidspunktet NULL oversettelsesinfrastruktur -- alt UI-tekst hardkodet
norsk. Kartlagt før bygging (Explore-agent + direkte grep): 70
komponentfiler (46 858 linjer) + 42 ruter, **~2200+ hardkodede norske
UI-strenger** i komponentene alene, PLUSS **479 `app_error(...)`-kall**
i backend med hardkodede norske feilmeldinger vist direkte til
brukeren. Bruker valgte, over to spørsmålsrunder, den største
muligheten begge ganger: bygg ekte motor nå (ikke bare til
veiledningen), fyll ut norsk OG engelsk nå (ikke bare norsk klar for
senere oversettelse).
**Eksplisitt faset, ikke "ferdig i én runde".** 2200+ strenger + 479
feilkoder er et flerdagers arbeid uansett tempo. Denne runden bygget
grunnmuren + de to konkrete tingene bruker faktisk ba om (veiledning +
tabellsammenslåing, se under) med full norsk+engelsk-dekning. Resten av
appen får en foreslått batch-rekkefølge i `FEATURE_BACKLOG.md` for
påfølgende, avgrensede runder -- ikke noe forsøk på å late som om alt
ble oversatt i denne ene runden.
**Arkitektur:**
- `next-intl` (offisiell App Router-støtte, ICU-meldingsformat).
- **INGEN URL-prefiks-routing** (`app/[locale]/...`). Undersøkt: 42
rutefiler har norske slugs (`logg-inn`/`velkommen`/
`kontosammenslaing`), og appen har mange delte/bokmerkede lenker
(offentlige turneringssider, magic-link-e-poster). Et locale-prefiks
ville brukket disse eller krevd en egen redirect-runde. Løst med
`i18n/request.ts` (server-only, leser KUN en cookie via
`next/headers`) + `NextIntlClientProvider` i `app/layout.tsx` (nå
async, `getLocale()`).
- **Delte, RENE konstanter i `lib/locale.ts`** (`SUPPORTED_LOCALES`/
`DEFAULT_LOCALE`/`LOCALE_COOKIE`/`isLocale`) -- funnet under bygging:
et første forsøk la disse i `i18n/request.ts` selv, som feilet i
klientkomponenter (`locale-bootstrap.tsx`) fordi HELE den filen ble
server-only via `next/headers`-importen (bekreftet med en ekte 500-
feil i lokal scratch-visning, "You're importing a module that
depends on next/headers ... in the Pages Router" til tross for at
dette er App Router -- next-intl sin bunt-analyse så gjennom
re-eksporten). Skilt ut i en egen fil uten server-only-imports, som
begge sider kan importere trygt.
- **Språkkilde:** cookie (satt av ny `language-switcher.tsx`, i "Mer"-
menyen) vinner hvis satt, ellers `preferred_locale` fra `/auth/me`
(fantes ALLEREDE siden ADR-015, aldri lest av frontend før nå -- ny
`locale-bootstrap.tsx` synker den inn FØRSTE gang, kun én gang, ikke
ved hvert besøk), ellers `"nb"`.
- `preferred_locale` kunne FØR kun settes ved førstegangs-signup.
Lagt til den allerede generiske `ProfileUpdate` (`PATCH /profile`,
`auth.py`) -- samme mønster som resten av appen, ingen ny
endepunkt-type.
- **Feilmeldinger fra backend oversettes IKKE denne runden -- reell
arkitektonisk begrensning oppdaget underveis, ikke bare utsatt av
bekvemmelighet.** Planen var opprinnelig kode→oversettelse-oppslag
(`body.detail.code`) med norsk fallback. Undersøkt før bygging: de
479 `app_error`-kallene bruker en LITEN, delt kodetaksonomi (f.eks.
`VALIDATION_FAILED`/`OUT_OF_SCOPE`/`NOT_FOUND` dekker dusinvis av
INNBYRDES ULIKE meldinger på tvers av appen). Én kode → én oversatt
tekst ville dermed gitt FEIL engelsk tekst for de fleste faktiske
feil. Ordentlig løsning krever enten finere koder (egen backend-
refaktorering) eller at backend selv blir locale-bevisst (bryter det
bevisste "ingen backend-endring for oversettelse"-valget). Feil-
meldinger forblir derfor norsk uansett språkvalg inntil videre --
et eksplisitt, dokumentert hull, ikke noe som blokkerer resten.
**Hjelpeside/FAQ (`/hjelp`, `help-page.tsx`):** oversiktside (turnerings-
flyten, samme forklaring gitt i chatten) + søkbar FAQ, startet med de
to konkrete spørsmålene fra samtalen ("hvordan fungerer
turneringsmodulen" / "hvordan legger jeg til spillere i
spillerpoolen"), pluss to til (forskjell deltaker/rundedeltakelse,
Cut, tiebreak). Strukturert innhold (`t.raw()`, ikke enkeltstrenger) i
`messages/{nb,en}.json`. Lenket fra "Mer"-menyen. Verifisert i lokal
scratch: full klikk-gjennom på BEGGE språk (cookie satt manuelt via
DevTools for å simulere bryteren), FAQ-utvidelse, lys+mørk -- fant og
fikset ett reelt hull underveis (søkefeltets plassholder/"Ingen treff"
var fortsatt hardkodet norsk, ikke fanget opp av `t()`-kallene rundt).
**Spillere-tabellen slått ENDA lenger sammen.** Bruker, i samme melding:
"Slå sammen de to tabellene også" (Deltakere + Rundedeltakelse fra
ADR-093, diskutert som en oppfølger dagen før). Ny
`tournament-players-table.tsx` (V0-generert) erstatter BEGGE: én rad
per deltaker, kolonner for HCP/klasse/statistikknivå/status/kjønn/alder
PLUSS én kolonnegruppe per runde (sjekkboks+utslagssted), i én bred
scrollbar tabell i stedet for to stablede. CSV-import og "legg til
deltaker"-kontrollene ble IKKE bygget om -- de fungerte fint fra før og
flyttet bare til å stå direkte i `SetupTab` sitt "players"-steg i
stedet for inni den nå fjernede `ParticipantsCard`. `HcpInput`
(egen liten inline-komponent) er også fjernet -- samme funksjon finnes
nå direkte i den nye tabellen.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: ny
`test_profile_locale.py` (3 tester) -- 152/152 grønt (opp fra 149).
Grundig scratch-runde av selve `TournamentPlayersTable`-komponenten
(mock-data, av-/påkrysning av rundedeltakelse fungerer og bruker riktig
standardutslag, "Fjern"-knapp fungerer, DSQ-status rødtonet, lys+mørk)
-- IKKE en full innlogget klikk-gjennom av den ferdig integrerte
versjonen mot ekte backend, vurdert tilstrekkelig dekket av at
komponenten selv er grundig testet isolert og props-sammenkoblingen er
typesjekket, gitt øktens allerede betydelige lengde.
**Rullet ut:** venter på bekreftelse.
--- ---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)

View file

@ -12981,3 +12981,40 @@ Neste steg:
**Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon. **Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon.
`docker compose build teecup_api teecup_frontend && up -d` for `docker compose build teecup_api teecup_frontend && up -d` for
begge, rene logger, 200 OK. begge, rene logger, 200 OK.
126. **Ekte oversettelsesinfrastruktur (next-intl) + hjelpeside/FAQ +
Spillere-tabellen slått enda mer sammen -- 2026-08-19 (ADR-094).**
Bruker ba om en veiledning ("alt skal være oversettbart"), fikk
beskjed om at appen hadde null i18n fra før (~2200 hardkodede
strenger, 479 backend-feilkoder), og valgte likevel den store
løsningen begge ganger spørsmålet ble stilt: ekte motor nå, norsk
OG engelsk fylt ut nå. `next-intl` installert, INGEN URL-prefiks
(norske slugs + delte/bokmerkede lenker gjorde det for kostbart) --
cookie-basert i stedet, med `preferred_locale` (fantes siden
ADR-015, aldri brukt av frontend) som standard for innloggede
brukere første gang. Fant og fikset en ekte modul-grense-bug
underveis (delte konstanter måtte ut i en egen `lib/locale.ts` --
en server-only-import i `i18n/request.ts` gjorde HELE filen
utilgjengelig for klientkomponenter, bekreftet med en reell 500-feil
i scratch). Fant og aksepterte en reell arkitekturbegrensning:
479 feilkoder er for grovkornet til å oversettes via kode alene
(samme kode dekker mange ulike meldinger) -- feilmeldinger forblir
norsk inntil videre, dokumentert som et bevisst hull. Ny `/hjelp`-
side (oversikt + søkbar FAQ, startet med de to konkrete
spørsmålene fra samtalen). `preferred_locale` gjort redigerbar
etter signup (var før låst). I SAMME melding: "Slå sammen de to
tabellene også" -- Deltakere (ADR-091) og Rundedeltakelse (ADR-093)
slått sammen til én `tournament-players-table.tsx` (V0), CSV-import
og "legg til deltaker" uendret, bare flyttet ut av den nå fjernede
`ParticipantsCard`.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 3 nye
backend-tester -- 152/152 grønt (opp fra 149). Full klikk-gjennom
av hjelpesiden på begge språk i scratch (fant og fikset ett
gjenværende hardkodet strengpar underveis). Grundig scratch-runde
av selve den nye tabellen isolert. Ingen full innlogget klikk-
gjennom av den ferdig integrerte versjonen -- vurdert tilstrekkelig
dekket gitt komponentens egen grundige testing + typesjekket
sammenkobling.
**Rullet ut:** venter på bekreftelse.

View file

@ -4645,3 +4645,36 @@ som selvregistrering), så importen selv trenger ikke håndtere
invitasjon -- bare opprette rader. invitasjon -- bare opprette rader.
Se ADR (skrives når planen er ferdig) for design/omfang. Se ADR (skrives når planen er ferdig) for design/omfang.
## Retro-oversettelse av eksisterende UI (nb/en) — 🔨 PÅBEGYNT 2026-08-19 (ADR-094), resten 📋 planlagt i batcher
Grunnmuren (`next-intl`, cookie-basert språkvalg, ingen URL-endring)
og to genuint nye flater (hjelpeside/FAQ, den sammenslåtte Spillere-
tabellen) er bygget og fylt ut på begge språk, se ADR-094. **De
resterende ~68 eksisterende komponentfilene (~2200 hardkodede norske
UI-strenger, kartlagt 2026-08-19) er IKKE rørt ennå** -- for stort til
å gjøre trygt i én sammenhengende runde. Foreslått batch-rekkefølge for
påfølgende, avgrensede økter (hver batch = egen runde med egen
verifisering, samme inkrementelle disiplin som resten av appen):
1. 📋 Innlogging/dashbord/navigasjon (`logg-inn`, `dashboard.tsx`,
`more-menu.tsx`, `bottom-nav.tsx` osv.) -- mest brukte flater,
høyest synlighet.
2. 📋 Turneringsoppsett-wizarden (ADR-092/093/094, nettopp bygget,
fortsatt fersk i minnet).
3. 📋 Scorekort/leaderboard -- spillerens kjerneopplevelse under selve
rundens gang.
4. 📋 Resten (spillerpool, order of merit, feed/venner,
kontoinnstillinger, presentasjon/sponsorer).
5. 📋 Backend-feilkoder (479 `app_error`-kall) -- dekkes LØPENDE
gjennom batch 1-4 (hver batch oversetter kodene den faktisk støter
på i sine egne skjermer), ikke som en egen runde. **Reell
arkitekturbegrensning, ikke bare rekkefølge:** dagens kodetaksonomi
(`VALIDATION_FAILED`/`OUT_OF_SCOPE`/`NOT_FOUND` osv.) er for
grovkornet -- samme kode brukes til dusinvis av innbyrdes ulike
meldinger på tvers av appen, så kode→tekst-oversettelse alene gir
feil resultat for de fleste faktiske feil. Krever enten finere
koder (egen backend-refaktorering, ikke påbegynt) eller at backend
selv blir locale-bevisst (bryter det bevisste "ingen backend-
endring for oversettelse"-valget i ADR-094). Feilmeldinger forblir
norsk uansett språkvalg inntil en av disse løsningene velges.

View file

@ -926,6 +926,12 @@ class ProfileUpdate(BaseModel):
# Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke # Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
# en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen. # en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
bag_clubs: list[BagClub] | None = Field(default=None, max_length=14) bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)
# i18n (ADR-094) -- preferred_locale kunne FØR kun settes ved
# førstegangs-signup (ADR-015, magic_link_token.locale), aldri endres
# etterpå. language-switcher.tsx PATCHer dette feltet slik at valget
# følger brukeren til neste enhet/økt, ikke bare gjeldende nettleser
# (som en cookie alene ville gitt).
preferred_locale: str | None = Field(default=None, pattern="^(nb|en)$")
@router.patch("/profile", response_model=Me) @router.patch("/profile", response_model=Me)

View file

@ -0,0 +1,5 @@
import { HelpPage } from "@/components/help-page"
export default function Hjelp() {
return <HelpPage />
}

View file

@ -1,6 +1,9 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { Nunito } from 'next/font/google' import { Nunito } from 'next/font/google'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { SwRegister } from '@/components/sw-register' import { SwRegister } from '@/components/sw-register'
import { LocaleBootstrap } from '@/components/locale-bootstrap'
import './globals.css' import './globals.css'
const nunito = Nunito({ const nunito = Nunito({
@ -52,16 +55,24 @@ export const viewport: Viewport = {
], ],
} }
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode children: React.ReactNode
}>) { }>) {
// i18n (ADR-094) -- locale kommer fra en cookie (se i18n/request.ts),
// ALDRI fra URL-en. LocaleBootstrap synker inn brukerens preferred_locale
// fra /auth/me på klienten dersom ingen cookie er satt ennå.
const locale = await getLocale()
return ( return (
<html lang="no" className={`${nunito.variable} bg-background`}> <html lang={locale} className={`${nunito.variable} bg-background`}>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<NextIntlClientProvider>
<SwRegister /> <SwRegister />
<LocaleBootstrap />
{children} {children}
</NextIntlClientProvider>
</body> </body>
</html> </html>
) )

View file

@ -0,0 +1,99 @@
"use client"
// Hjelpeside (ADR-094) -- oversiktside for turneringsflyten + søkbar FAQ.
// Innholdet ligger i messages/{nb,en}.json under "help" (t.raw(), siden
// dette er strukturert innhold -- arrays av {title,body}/{question,answer}
// -- ikke enkeltstrenger). Startet med to konkrete spørsmål brukeren
// faktisk stilte i en samtale ("hvordan fungerer turneringsmodulen" /
// "hvordan legger jeg til spillere i spillerpoolen") -- ment å vokse
// organisk etter hvert som flere spørsmål dukker opp, samme mønster som
// resten av appen er bygget etter denne økten.
import { useState } from "react"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { ArrowLeft, Search } from "lucide-react"
type OverviewSection = { title: string; body: string }
type FaqEntry = { question: string; answer: string }
export function HelpPage() {
const t = useTranslations("help")
const [query, setQuery] = useState("")
const sections = t.raw("overview.sections") as OverviewSection[]
const faq = t.raw("faq") as FaqEntry[]
const filteredFaq =
query.trim() === ""
? faq
: faq.filter(
(entry) =>
entry.question.toLowerCase().includes(query.toLowerCase()) ||
entry.answer.toLowerCase().includes(query.toLowerCase()),
)
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href="/more"
aria-label={t("backLabel")}
className="flex size-11 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>
<h1 className="text-xl font-extrabold tracking-tight text-foreground">{t("pageTitle")}</h1>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-foreground">{t("overview.title")}</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{t("overview.intro")}</p>
</div>
<div className="flex flex-col gap-4">
{sections.map((section, i) => (
<div key={i} className="flex flex-col gap-1">
<h3 className="text-base font-bold text-foreground">{section.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{section.body}</p>
</div>
))}
</div>
</section>
<section className="mt-6 flex flex-col gap-3">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchLabel")}
className="h-11 w-full rounded-xl border border-border bg-card pl-11 pr-3 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{filteredFaq.length === 0 && (
<p className="p-5 text-center text-sm text-muted-foreground">{t("noResults")}</p>
)}
{filteredFaq.map((entry, i) => (
<details key={i} className="group p-4 sm:p-5">
<summary className="cursor-pointer list-none text-base font-bold text-foreground marker:content-none">
{entry.question}
</summary>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground text-pretty">{entry.answer}</p>
</details>
))}
</div>
</section>
</main>
</div>
)
}

View file

@ -47,7 +47,7 @@ import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel" import { PlayerImportPanel } from "@/components/player-import-panel"
import { RoundGroupsPanel } from "@/components/round-groups-panel" import { RoundGroupsPanel } from "@/components/round-groups-panel"
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list" import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
import { RoundParticipationTable } from "@/components/round-participation-table" import { TournamentPlayersTable } from "@/components/tournament-players-table"
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card" import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav" import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
import { import {
@ -1200,6 +1200,7 @@ function SetupTab({
}) { }) {
const [step, setStep] = useState<string>("basics") const [step, setStep] = useState<string>("basics")
const [groupsOpenFor, setGroupsOpenFor] = useState<string | null>(null) const [groupsOpenFor, setGroupsOpenFor] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
async function refreshPool() { async function refreshPool() {
@ -1454,46 +1455,53 @@ function SetupTab({
)} )}
{step === "players" && ( {step === "players" && (
<div className="flex flex-col gap-6"> <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<ParticipantsCard <div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Spillere</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{participants.length}
</span>
</div>
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11 self-start">
<Upload aria-hidden="true" className="mr-2 size-4" />
Importer fra CSV
</Button>
{showImport && (
<PlayerImportPanel
organizationId={organizationId} organizationId={organizationId}
tournamentId={tournament.id} tournamentId={tournament.id}
participants={participants} mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
pool={pool} onClose={() => setShowImport(false)}
classes={classes} onImportComplete={() => {
onAddParticipant={onAddParticipant} void (async () => {
onAddNewPlayer={onAddNewPlayer}
onImportComplete={async () => {
await Promise.all([refreshPool(), onImportComplete()]) await Promise.all([refreshPool(), onImportComplete()])
})()
}} }}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
onSetHandicap={onSetParticipantHandicap}
onSetStatus={onSetParticipantStatus}
/> />
)}
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"> {participants.length === 0 ? (
<div className="flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Rundedeltakelse</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty"> <p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Hvem spiller hvilke runder, fra hvilket utslagssted -- for hele turneringen én gang. Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
</p> </p>
</div> ) : rounds.length > 0 && roundParticipantsByRound === null ? (
{rounds.length === 0 ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen runder ennå -- legg til runder under «Runder og baner» først.
</p>
) : roundParticipantsByRound === null ? (
<p className="text-sm text-muted-foreground">Laster</p> <p className="text-sm text-muted-foreground">Laster</p>
) : ( ) : (
<RoundParticipationTable <TournamentPlayersTable
organizationId={organizationId}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
players={participants.map((p) => { players={participants.map((p) => {
const player = pool.find((pl) => pl.id === p.player_id) const player = pool.find((pl) => pl.id === p.player_id)
return { return {
participantId: p.id, participantId: p.id,
playerId: p.player_id, playerId: p.player_id,
name: p.player_name, playerName: p.player_name,
handicapSnapshot: p.handicap_index_snapshot,
classId: p.class_id,
statLevel: p.stat_level,
status: p.status,
gender: player?.gender ?? null, gender: player?.gender ?? null,
birthDate: player?.birth_date ?? null, birthDate: player?.birth_date ?? null,
} }
@ -1508,7 +1516,7 @@ function SetupTab({
r.id, r.id,
Object.fromEntries( Object.fromEntries(
participants.map((p) => { participants.map((p) => {
const rp = (roundParticipantsByRound[r.id] ?? []).find( const rp = (roundParticipantsByRound?.[r.id] ?? []).find(
(x) => x.tournament_participant_id === p.id, (x) => x.tournament_participant_id === p.id,
) )
return [ return [
@ -1521,17 +1529,28 @@ function SetupTab({
), ),
]), ]),
)} )}
onToggle={toggleRoundParticipant} onSetHandicap={onSetParticipantHandicap}
onBulkToggle={bulkSetRoundParticipation} onSetClass={onSetParticipantClass}
onChangeTee={changeRoundParticipantTee} onSetStatLevel={onSetParticipantStatLevel}
onSetStatus={onSetParticipantStatus}
onRemoveParticipant={onRemoveParticipant}
onChangeGender={changePlayerGender} onChangeGender={changePlayerGender}
onChangeBirthDate={changePlayerBirthDate} onChangeBirthDate={changePlayerBirthDate}
onToggleRound={toggleRoundParticipant}
onBulkToggleRound={bulkSetRoundParticipation}
onChangeRoundTee={changeRoundParticipantTee}
busyRoundIds={busyRoundIds} busyRoundIds={busyRoundIds}
errors={participationErrors} errors={participationErrors}
/> />
)} )}
<AddParticipantControl
pool={pool}
alreadyIn={new Set(participants.map((p) => p.player_id))}
onAddExisting={onAddParticipant}
onAddNew={onAddNewPlayer}
/>
</section> </section>
</div>
)} )}
{step === "groups" && ( {step === "groups" && (
@ -2001,242 +2020,6 @@ function ClassesCard({
) )
} }
const STAT_LEVEL_LABELS: Record<string, string> = {
strokes_only: "Kun slag",
strokes_and_putts: "Slag og putter",
full: "All statistikk",
}
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
// sette Dsq, Rtd, Dnf, og Dns." Blokkerer videre score i ALLE runder
// (strengere enn Cut) og overstyrer leaderboardets total-visning.
const PARTICIPANT_STATUS_LABELS: Record<string, string> = {
active: "Aktiv",
dsq: "DSQ",
rtd: "RTD",
dnf: "DNF",
dns: "DNS",
}
function ParticipantsCard({
organizationId,
tournamentId,
participants,
pool,
classes,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onSetClass,
onSetStatLevel,
onSetHandicap,
onSetStatus,
}: {
organizationId: string
tournamentId: string
participants: ApiParticipant[]
pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void>
onSetStatLevel: (
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
onSetHandicap: (participantId: string, handicap: number | null) => Promise<void>
onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise<void>
}) {
const [showImport, setShowImport] = useState(false)
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Deltakere</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{participants.length}
</span>
</div>
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11 self-start">
<Upload aria-hidden="true" className="mr-2 size-4" />
Importer fra CSV
</Button>
{showImport && (
<PlayerImportPanel
organizationId={organizationId}
tournamentId={tournamentId}
mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
onClose={() => setShowImport(false)}
onImportComplete={() => {
void onImportComplete()
}}
/>
)}
{participants.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
</p>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{participants.map((p) => (
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
<HcpInput
playerName={p.player_name}
value={p.handicap_index_snapshot}
onCommit={(v) => onSetHandicap(p.id, v)}
/>
{/* Direkte kobling til spillerpoolen (2026-08-18, "det bør
dessuten være kobling direkte mellom spillerpool og
turneringsside") -- for å rette navn/e-post/kjønn/klubb
osv., som IKKE er turnering-spesifikke felt (kun HCP er
det, se HcpInput). Åpner spillerpoolen direkte denne
spillerens rad, i stedet for at organisator lete den
opp manuelt i en potensielt lang tabell. */}
<Link
href={`/organizations/${organizationId}/players?highlight=${encodeURIComponent(p.player_id)}`}
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
<ExternalLink aria-hidden="true" className="size-3" />
Rediger i spillerpoolen
</Link>
</div>
{classes.length > 0 && (
<select
value={p.class_id ?? ""}
onChange={(e) => onSetClass(p.id, e.target.value || null)}
aria-label={`Klasse for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
{/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye
HoleGrid/HoleStatsSheet ber om ved scoring for DENNE
deltakeren, tournament-bredt (ikke per runde). */}
<select
value={p.stat_level}
onChange={(e) =>
onSetStatLevel(p.id, e.target.value as "strokes_only" | "strokes_and_putts" | "full")
}
aria-label={`Statistikknivå for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
{Object.entries(STAT_LEVEL_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{/* Manuell status (migrasjon 087, 2026-08-18) -- blokkerer
videre score i ALLE runder når satt til noe annet enn
"Aktiv", overstyrer leaderboardets total-visning. */}
<select
value={p.status}
onChange={(e) =>
onSetStatus(p.id, e.target.value as "active" | "dsq" | "rtd" | "dnf" | "dns")
}
aria-label={`Status for ${p.player_name}`}
className={cn(
"h-9 shrink-0 rounded-lg border px-2 text-xs font-bold outline-none",
p.status === "active"
? "border-border bg-card text-foreground"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
{Object.entries(PARTICIPANT_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemoveParticipant(p.id)}
aria-label={`Fjern ${p.player_name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
<AddParticipantControl
pool={pool}
alreadyIn={new Set(participants.map((p) => p.player_id))}
onAddExisting={onAddParticipant}
onAddNew={onAddNewPlayer}
/>
</section>
)
}
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
// lokal utkast-tilstand, forpliktet KUN på blur (ikke per tastetrykk) --
// samme mønster som tallfelt ellers i appen. Tomt felt = fjern HCP-en
// (spilleren mister evt. adgang til netto-/Stableford-runder som krever
// den, samme validering som ved ny påmelding).
function HcpInput({
playerName,
value,
onCommit,
}: {
playerName: string
value: number | null
onCommit: (next: number | null) => Promise<void>
}) {
const [draft, setDraft] = useState(value !== null ? String(value) : "")
useEffect(() => {
setDraft(value !== null ? String(value) : "")
}, [value])
function commit() {
const trimmed = draft.trim()
if (trimmed === "") {
if (value !== null) void onCommit(null)
return
}
const n = Number(trimmed)
if (Number.isFinite(n) && n !== value) void onCommit(n)
}
return (
<label className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">Hcp</span>
<input
type="number"
inputMode="decimal"
step={0.1}
min={-10}
max={54}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
aria-label={`HCP for ${playerName}`}
placeholder="—"
className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-xs font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
)
}
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord = // Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin // fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra // backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
@ -3023,13 +2806,14 @@ function RoundCard({
} }
// Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i // Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i
// rundedeltakelse-tabellen (ADR-093) -- samme oppløsning som den nå // den sammenslåtte spillertabellen (ADR-093/094, tournament-players-
// fjernede AssignRoundParticipantControl brukte for ett-om-gangen- // table.tsx) -- samme oppløsning som den nå fjernede
// tilordning: deltakerens klasses standardutslag, KUN hvis det faktisk // AssignRoundParticipantControl brukte for ett-om-gangen-tilordning:
// finnes på den aktuelle rundens bane (klassens standardutslag kan // deltakerens klasses standardutslag, KUN hvis det faktisk finnes på
// tilhøre en annen bane). Faller tilbake til rundens eget valgte // den aktuelle rundens bane (klassens standardutslag kan tilhøre en
// "Standardutslag" (lokal UI-state i round-participation-table.tsx), // annen bane). Faller tilbake til rundens eget valgte "Standardutslag"
// og til slutt til det første utslaget i lista. // (lokal UI-state inni tournament-players-table.tsx), og til slutt til
// det første utslaget i lista.
function resolveDefaultTeeId( function resolveDefaultTeeId(
participant: ApiParticipant, participant: ApiParticipant,
classes: ApiTournamentClass[], classes: ApiTournamentClass[],

View file

@ -0,0 +1,71 @@
"use client"
// Språkbryter (ADR-094) -- setter locale-cookien direkte (samme cookie
// i18n/request.ts leser) og PATCHer /profile sin preferred_locale når
// innlogget, slik at valget følger brukeren til neste enhet/økt også
// (ikke bare denne nettleseren). cookie alene er nok til at selve
// visningen endres umiddelbart -- PATCH-en er en "husk til neste gang"-
// bonus, feiler stille (utlogget bruker har ingenting å PATCHe mot).
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useLocale, useTranslations } from "next-intl"
import { cn } from "@/lib/utils"
import { LOCALE_COOKIE, SUPPORTED_LOCALES, type Locale } from "@/lib/locale"
export function LanguageSwitcher() {
const locale = useLocale()
const t = useTranslations("common.languageSwitcher")
const router = useRouter()
const [pending, setPending] = useState(false)
async function setLocale(next: Locale) {
if (next === locale || pending) return
setPending(true)
document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=31536000; SameSite=Lax`
try {
await fetch("/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ preferred_locale: next }),
})
} catch {
// Stille -- cookien er allerede satt, selve visningen fungerer uansett.
}
router.refresh()
setPending(false)
}
const labels: Record<Locale, string> = { nb: t("norwegian"), en: t("english") }
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-bold text-foreground">{t("label")}</span>
<div role="radiogroup" aria-label={t("label")} className="flex gap-2">
{SUPPORTED_LOCALES.map((code) => {
const selected = code === locale
return (
<button
key={code}
type="button"
role="radio"
aria-checked={selected}
disabled={pending}
onClick={() => setLocale(code)}
className={cn(
"min-h-11 flex-1 rounded-xl border px-4 text-sm font-bold transition-all duration-200 ease-in-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{labels[code]}
</button>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,45 @@
"use client"
// i18n (ADR-094) -- monteres én gang i rot-layouten. Selve locale-
// oppløsningen på serveren (i18n/request.ts) leser KUN en cookie, uten
// noen fetch-avhengighet (rask, synkron). Denne komponenten er stedet
// der brukerens preferred_locale (allerede på backend siden ADR-015,
// aldri lest av frontend før nå) faktisk får virkning: FØRSTE gang en
// innlogget bruker besøker appen uten at cookien er satt ennå, hentes
// /auth/me og cookien settes deretter -- kun én gang, ikke ved hvert
// besøk (cookien vinner etter det, selv om brukeren senere endrer
// preferred_locale et annet sted, inntil de bruker språkbryteren igjen).
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/locale"
function hasLocaleCookie(): boolean {
return document.cookie.split("; ").some((c) => c.startsWith(`${LOCALE_COOKIE}=`))
}
export function LocaleBootstrap() {
const router = useRouter()
useEffect(() => {
if (hasLocaleCookie()) return
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((me: { preferred_locale?: string } | null) => {
if (cancelled || !me) return
const locale = isLocale(me.preferred_locale) ? me.preferred_locale : DEFAULT_LOCALE
if (locale === DEFAULT_LOCALE) return // allerede det serveren rendret med
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`
router.refresh()
})
.catch(() => {
// Stille -- utlogget/nettverksfeil betyr bare at nb-standarden
// (allerede rendret) beholdes.
})
return () => {
cancelled = true
}
}, [router])
return null
}

View file

@ -10,8 +10,9 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react" import { Bell, Building2, ChevronRight, CircleHelp, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
import { BottomNav } from "@/components/teecup/bottom-nav" import { BottomNav } from "@/components/teecup/bottom-nav"
import { LanguageSwitcher } from "@/components/language-switcher"
const C = { const C = {
card: "#ffffff", card: "#ffffff",
@ -66,7 +67,8 @@ export function MoreMenu() {
<MenuLink href="/account" icon={UserCircle} label="Konto" /> <MenuLink href="/account" icon={UserCircle} label="Konto" />
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" /> <MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
<MenuLink href="/my-feed" icon={MessageSquare} label="Feed" /> <MenuLink href="/my-feed" icon={MessageSquare} label="Feed" />
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} /> <MenuLink href="/my-notifications" icon={Bell} label="Varsler" />
<MenuLink href="/hjelp" icon={CircleHelp} label="Hjelp" last={me?.organizations.length === 0} />
{me?.organizations.map((o, i) => ( {me?.organizations.map((o, i) => (
<MenuLink <MenuLink
key={o.organization_id} key={o.organization_id}
@ -78,6 +80,10 @@ export function MoreMenu() {
))} ))}
</ul> </ul>
<div className="mt-6 rounded-2xl p-4" style={{ border: `1px solid ${C.border}`, backgroundColor: C.card }}>
<LanguageSwitcher />
</div>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}

View file

@ -1,345 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Loader2, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
// V0-generert (ADR-093, 2026-08-19) -- ren kontrollert/presentasjonell
// komponent, ingen egne fetch-kall. SetupTab (individual-tournament-
// detail.tsx) eier all datahenting/orkestrering og sender ferdig
// sammenslått data + callback-props inn.
export type RoundParticipationPlayer = {
participantId: string
playerId: string
name: string
gender: "m" | "f" | "x" | null
birthDate: string | null // ISO "YYYY-MM-DD", null if unknown
}
export type RoundParticipationRound = {
id: string
label: string // e.g. "Runde 1"
tees: { id: string; name: string }[] // this round's course's available tees
}
export type RoundParticipationCell = {
assigned: boolean
teeId: string | null // null when not assigned
teeName: string | null
}
export type RoundParticipationTableProps = {
players: RoundParticipationPlayer[]
rounds: RoundParticipationRound[]
// cells[roundId][participantId]
cells: Record<string, Record<string, RoundParticipationCell>>
onToggle: (roundId: string, participantId: string, checked: boolean, teeId: string) => void
onBulkToggle: (roundId: string, checked: boolean, teeId: string) => void
onChangeTee: (roundId: string, participantId: string, teeId: string) => void
onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void
onChangeBirthDate: (playerId: string, birthDate: string) => void
busyRoundIds: string[]
errors: string[]
}
// Standard age-from-birthdate: subtract years, then step back one if this
// year's birthday hasn't happened yet.
function computeAge(birthDate: string): number {
const birth = new Date(birthDate)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const monthDiff = now.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
age -= 1
}
return age
}
const selectClass =
"h-10 rounded-lg border border-border bg-background px-2 text-sm font-medium text-foreground transition-all duration-200 ease-in-out hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
export function RoundParticipationTable({
players,
rounds,
cells,
onToggle,
onBulkToggle,
onChangeTee,
onChangeGender,
onChangeBirthDate,
busyRoundIds,
errors,
}: RoundParticipationTableProps) {
// Local-only convenience state: each round column's "Standardutslag"
// selection, used to pre-fill the teeId when checking a box. Resets to each
// round's first tee on mount / when the round set changes.
const [defaultTees, setDefaultTees] = useState<Record<string, string>>(() =>
Object.fromEntries(rounds.map((r) => [r.id, r.tees[0]?.id ?? ""])),
)
useEffect(() => {
setDefaultTees((prev) => {
const next: Record<string, string> = {}
for (const r of rounds) {
// keep an existing valid selection, otherwise fall back to first tee
const existing = prev[r.id]
const stillValid = existing && r.tees.some((t) => t.id === existing)
next[r.id] = stillValid ? existing : (r.tees[0]?.id ?? "")
}
return next
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rounds.map((r) => r.id + ":" + r.tees.map((t) => t.id).join(",")).join("|")])
return (
<div className="flex flex-col gap-4">
{errors.length > 0 && (
<div
role="alert"
className="flex flex-col gap-1.5 rounded-2xl border border-destructive/30 bg-destructive/10 p-4"
>
<div className="flex items-center gap-2 font-bold text-destructive">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
<span>Noen endringer feilet</span>
</div>
<ul className="flex flex-col gap-1 pl-7 text-sm text-destructive">
{errors.map((msg, i) => (
<li key={i} className="text-pretty leading-relaxed">
{msg}
</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<table className="w-full border-collapse text-left">
<thead>
<tr className="divide-x divide-border border-b border-border">
<th
scope="col"
className="sticky left-0 z-20 min-w-48 bg-card px-4 py-3 align-bottom text-sm font-bold text-foreground"
>
Spiller
</th>
<th scope="col" className="min-w-32 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
Kjønn
</th>
<th scope="col" className="min-w-56 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
Alder
</th>
{rounds.map((round) => {
const busy = busyRoundIds.includes(round.id)
return (
<th
key={round.id}
scope="col"
className="min-w-56 bg-card px-3 py-3 align-bottom"
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-foreground">{round.label}</span>
{busy && (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-muted-foreground">
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
Oppdaterer
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">Standardutslag</span>
<select
value={defaultTees[round.id] ?? ""}
disabled={busy}
onChange={(e) =>
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
}
className={selectClass}
aria-label={`Standardutslag for ${round.label}`}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
</label>
<BulkToggle
round={round}
players={players}
cells={cells}
busy={busy}
onBulkToggle={(checked) =>
onBulkToggle(round.id, checked, defaultTees[round.id] ?? "")
}
/>
</div>
</div>
</th>
)
})}
</tr>
</thead>
<tbody className="divide-y divide-border">
{players.map((player) => {
const age = player.birthDate ? computeAge(player.birthDate) : null
return (
<tr key={player.participantId} className="divide-x divide-border">
<th
scope="row"
className="sticky left-0 z-10 bg-card px-4 py-3 text-left font-bold text-foreground"
>
{player.name}
</th>
{/* Kjønn */}
<td className="px-3 py-3">
<select
value={player.gender ?? ""}
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
className={cn(selectClass, "w-full", player.gender === null && "text-muted-foreground")}
aria-label={`Kjønn for ${player.name}`}
>
<option value="" disabled>
Ikke satt
</option>
<option value="m">Mann</option>
<option value="f">Kvinne</option>
<option value="x">Annet</option>
</select>
</td>
{/* Alder */}
<td className="px-3 py-3">
<div className="flex items-center gap-2">
<input
type="date"
value={player.birthDate ?? ""}
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
className={cn(selectClass, "w-36")}
aria-label={`Fødselsdato for ${player.name}`}
/>
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{age !== null ? `${age} år` : "Ukjent"}
</span>
</div>
</td>
{/* Round columns */}
{rounds.map((round) => {
const busy = busyRoundIds.includes(round.id)
const cell = cells[round.id]?.[player.participantId]
const assigned = cell?.assigned ?? false
const cellId = `cell-${round.id}-${player.participantId}`
return (
<td key={round.id} className="px-3 py-3">
<div className="flex items-center gap-2">
<label
htmlFor={cellId}
className={cn(
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
busy && "pointer-events-none opacity-50",
)}
>
<input
id={cellId}
type="checkbox"
checked={assigned}
disabled={busy}
onChange={(e) =>
onToggle(
round.id,
player.participantId,
e.target.checked,
e.target.checked ? (defaultTees[round.id] ?? "") : (cell?.teeId ?? ""),
)
}
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span className="text-sm font-medium text-foreground">
{assigned ? "Spiller" : "Ikke satt"}
</span>
</label>
{assigned && (
<select
value={cell?.teeId ?? ""}
disabled={busy}
onChange={(e) => onChangeTee(round.id, player.participantId, e.target.value)}
className={selectClass}
aria-label={`Utslagssted for ${player.name} i ${round.label}`}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
)}
</div>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
function BulkToggle({
round,
players,
cells,
busy,
onBulkToggle,
}: {
round: RoundParticipationRound
players: RoundParticipationPlayer[]
cells: Record<string, Record<string, RoundParticipationCell>>
busy: boolean
onBulkToggle: (checked: boolean) => void
}) {
const ref = useRef<HTMLInputElement>(null)
const assignedCount = players.reduce(
(acc, p) => acc + (cells[round.id]?.[p.participantId]?.assigned ? 1 : 0),
0,
)
const total = players.length
const allAssigned = total > 0 && assignedCount === total
const someAssigned = assignedCount > 0 && assignedCount < total
useEffect(() => {
if (ref.current) ref.current.indeterminate = someAssigned
}, [someAssigned])
const id = `bulk-${round.id}`
return (
<label
htmlFor={id}
className={cn(
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
busy && "pointer-events-none opacity-50",
)}
>
<input
id={id}
ref={ref}
type="checkbox"
checked={allAssigned}
disabled={busy}
onChange={() => onBulkToggle(!allAssigned)}
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span className="text-sm font-semibold text-foreground">
{allAssigned ? "Velg ingen" : "Velg alle"}
</span>
</label>
)
}

View file

@ -0,0 +1,527 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { ExternalLink, Loader2, Trash2, TriangleAlert } from "lucide-react"
export type TournamentPlayerRow = {
participantId: string
playerId: string
playerName: string
handicapSnapshot: number | null
classId: string | null
statLevel: "strokes_only" | "strokes_and_putts" | "full"
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
gender: "m" | "f" | "x" | null
birthDate: string | null // ISO "YYYY-MM-DD", null if unknown
}
export type ClassOption = { id: string; name: string }
export type TournamentPlayersRound = {
id: string
label: string // e.g. "Runde 1"
tees: { id: string; name: string }[]
}
export type TournamentPlayersCell = {
assigned: boolean
teeId: string | null
teeName: string | null
}
export type TournamentPlayersTableProps = {
players: TournamentPlayerRow[]
classes: ClassOption[]
rounds: TournamentPlayersRound[]
// cells[roundId][participantId]
cells: Record<string, Record<string, TournamentPlayersCell>>
organizationId: string
onSetHandicap: (participantId: string, value: number | null) => void
onSetClass: (participantId: string, classId: string | null) => void
onSetStatLevel: (participantId: string, statLevel: TournamentPlayerRow["statLevel"]) => void
onSetStatus: (participantId: string, status: TournamentPlayerRow["status"]) => void
onRemoveParticipant: (participantId: string) => void
onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void
onChangeBirthDate: (playerId: string, birthDate: string) => void
onToggleRound: (roundId: string, participantId: string, checked: boolean, teeId: string) => void
onBulkToggleRound: (roundId: string, checked: boolean, teeId: string) => void
onChangeRoundTee: (roundId: string, participantId: string, teeId: string) => void
busyRoundIds: string[]
errors: string[]
}
const STAT_LEVELS: { label: string; value: TournamentPlayerRow["statLevel"] }[] = [
{ label: "Kun slag", value: "strokes_only" },
{ label: "Slag og putt", value: "strokes_and_putts" },
{ label: "Fullt", value: "full" },
]
const STATUSES: { label: string; value: TournamentPlayerRow["status"] }[] = [
{ label: "Aktiv", value: "active" },
{ label: "DSQ", value: "dsq" },
{ label: "RTD", value: "rtd" },
{ label: "DNF", value: "dnf" },
{ label: "DNS", value: "dns" },
]
// Shared control styling so every inline field feels like one family.
const CONTROL =
"h-9 rounded-lg border border-border bg-background px-2.5 text-sm text-foreground transition-all duration-200 ease-in-out hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
function computeAge(birthDate: string): number | null {
const birth = new Date(birthDate)
if (Number.isNaN(birth.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
const monthDiff = now.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
age -= 1
}
return age
}
export function TournamentPlayersTable({
players,
classes,
rounds,
cells,
organizationId,
onSetHandicap,
onSetClass,
onSetStatLevel,
onSetStatus,
onRemoveParticipant,
onChangeGender,
onChangeBirthDate,
onToggleRound,
onBulkToggleRound,
onChangeRoundTee,
busyRoundIds,
errors,
}: TournamentPlayersTableProps) {
const showClassColumn = classes.length > 0
// Local-only: each round's "Standardutslag" (default tee) selection.
// Resets to each round's first tee whenever the round set changes.
const [defaultTees, setDefaultTees] = useState<Record<string, string>>({})
useEffect(() => {
const next: Record<string, string> = {}
for (const round of rounds) {
next[round.id] = round.tees[0]?.id ?? ""
}
setDefaultTees(next)
}, [rounds])
return (
<div className="flex flex-col gap-4">
{errors.length > 0 && (
<div
role="alert"
className="flex flex-col gap-1 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-destructive"
>
<div className="flex items-center gap-2">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
<h3 className="text-base font-bold">Noen endringer feilet</h3>
</div>
<ul className="pl-7 text-sm leading-relaxed">
{errors.map((err, i) => (
<li key={i} className="list-disc">
{err}
</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<table className="w-full border-collapse text-left">
<thead>
<tr className="divide-x divide-border border-b border-border">
<th
scope="col"
className="sticky left-0 z-20 bg-card px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"
>
Spiller
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Hcp
</th>
{showClassColumn && (
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Klasse
</th>
)}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Statistikknivå
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Status
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Kjønn
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Alder
</th>
{rounds.map((round) => {
const busy = busyRoundIds.includes(round.id)
const assignedCount = players.filter(
(p) => cells[round.id]?.[p.participantId]?.assigned,
).length
const allAssigned = players.length > 0 && assignedCount === players.length
const someAssigned = assignedCount > 0 && assignedCount < players.length
return (
<th
key={round.id}
scope="col"
className="min-w-52 px-4 py-3 align-top text-xs font-bold uppercase tracking-wide text-muted-foreground"
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span>{round.label}</span>
{busy && (
<span className="flex items-center gap-1 normal-case text-primary">
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
<span className="text-[11px] font-semibold">Oppdaterer</span>
</span>
)}
</div>
<label className="flex flex-col gap-1 normal-case">
<span className="text-[11px] font-semibold text-muted-foreground">Standardutslag</span>
<select
aria-label={`Standardutslag for ${round.label}`}
className={CONTROL}
value={defaultTees[round.id] ?? ""}
disabled={busy}
onChange={(e) =>
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
</label>
<BulkCheckbox
checked={allAssigned}
indeterminate={someAssigned}
disabled={busy || players.length === 0}
label="Velg alle"
onChange={(next) =>
onBulkToggleRound(round.id, next, defaultTees[round.id] ?? round.tees[0]?.id ?? "")
}
/>
</div>
</th>
)
})}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Spillerpool
</th>
<th scope="col" className="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
Fjern
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{players.map((player) => (
<PlayerRow
key={player.participantId}
player={player}
classes={classes}
showClassColumn={showClassColumn}
rounds={rounds}
cells={cells}
organizationId={organizationId}
defaultTees={defaultTees}
busyRoundIds={busyRoundIds}
onSetHandicap={onSetHandicap}
onSetClass={onSetClass}
onSetStatLevel={onSetStatLevel}
onSetStatus={onSetStatus}
onRemoveParticipant={onRemoveParticipant}
onChangeGender={onChangeGender}
onChangeBirthDate={onChangeBirthDate}
onToggleRound={onToggleRound}
onChangeRoundTee={onChangeRoundTee}
/>
))}
</tbody>
</table>
</div>
</div>
)
}
function BulkCheckbox({
checked,
indeterminate,
disabled,
label,
onChange,
}: {
checked: boolean
indeterminate: boolean
disabled: boolean
label: string
onChange: (next: boolean) => void
}) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate && !checked
}, [indeterminate, checked])
return (
<label className="flex items-center gap-2 normal-case">
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="text-[11px] font-semibold text-muted-foreground">{label}</span>
</label>
)
}
function PlayerRow({
player,
classes,
showClassColumn,
rounds,
cells,
organizationId,
defaultTees,
busyRoundIds,
onSetHandicap,
onSetClass,
onSetStatLevel,
onSetStatus,
onRemoveParticipant,
onChangeGender,
onChangeBirthDate,
onToggleRound,
onChangeRoundTee,
}: {
player: TournamentPlayerRow
classes: ClassOption[]
showClassColumn: boolean
rounds: TournamentPlayersRound[]
cells: Record<string, Record<string, TournamentPlayersCell>>
organizationId: string
defaultTees: Record<string, string>
busyRoundIds: string[]
onSetHandicap: (participantId: string, value: number | null) => void
onSetClass: (participantId: string, classId: string | null) => void
onSetStatLevel: (participantId: string, statLevel: TournamentPlayerRow["statLevel"]) => void
onSetStatus: (participantId: string, status: TournamentPlayerRow["status"]) => void
onRemoveParticipant: (participantId: string) => void
onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void
onChangeBirthDate: (playerId: string, birthDate: string) => void
onToggleRound: (roundId: string, participantId: string, checked: boolean, teeId: string) => void
onChangeRoundTee: (roundId: string, participantId: string, teeId: string) => void
}) {
// Handicap is a local draft committed on blur, not per-keystroke.
const [hcpDraft, setHcpDraft] = useState(
player.handicapSnapshot === null ? "" : String(player.handicapSnapshot),
)
useEffect(() => {
setHcpDraft(player.handicapSnapshot === null ? "" : String(player.handicapSnapshot))
}, [player.handicapSnapshot])
const age = player.birthDate ? computeAge(player.birthDate) : null
const statusAbnormal = player.status !== "active"
function commitHcp() {
const trimmed = hcpDraft.trim()
if (trimmed === "") {
onSetHandicap(player.participantId, null)
return
}
const num = Number(trimmed)
if (Number.isNaN(num)) {
// Revert bad input back to the last known good value.
setHcpDraft(player.handicapSnapshot === null ? "" : String(player.handicapSnapshot))
return
}
const clamped = Math.min(54, Math.max(-10, num))
onSetHandicap(player.participantId, clamped)
}
return (
<tr className="divide-x divide-border transition-colors duration-200 ease-in-out hover:bg-accent/40">
<th
scope="row"
className="sticky left-0 z-10 bg-card px-4 py-3 text-left align-middle text-sm font-bold text-foreground"
>
{player.playerName}
</th>
<td className="px-4 py-3 align-middle">
<input
type="number"
inputMode="decimal"
step={0.1}
min={-10}
max={54}
aria-label={`Handicap for ${player.playerName}`}
value={hcpDraft}
onChange={(e) => setHcpDraft(e.target.value)}
onBlur={commitHcp}
className={`${CONTROL} w-20 text-right tabular-nums`}
/>
</td>
{showClassColumn && (
<td className="px-4 py-3 align-middle">
<select
aria-label={`Klasse for ${player.playerName}`}
className={`${CONTROL} w-40`}
value={player.classId ?? ""}
onChange={(e) => onSetClass(player.participantId, e.target.value === "" ? null : e.target.value)}
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</td>
)}
<td className="px-4 py-3 align-middle">
<select
aria-label={`Statistikknivå for ${player.playerName}`}
className={`${CONTROL} w-36`}
value={player.statLevel}
onChange={(e) => onSetStatLevel(player.participantId, e.target.value as TournamentPlayerRow["statLevel"])}
>
{STAT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 align-middle">
<select
aria-label={`Status for ${player.playerName}`}
className={`${CONTROL} w-28 ${
statusAbnormal ? "border-destructive/40 bg-destructive/10 text-destructive" : ""
}`}
value={player.status}
onChange={(e) => onSetStatus(player.participantId, e.target.value as TournamentPlayerRow["status"])}
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 align-middle">
<select
aria-label={`Kjønn for ${player.playerName}`}
className={`${CONTROL} w-28`}
value={player.gender ?? ""}
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
>
<option value="" disabled>
Ikke satt
</option>
<option value="m">Mann</option>
<option value="f">Kvinne</option>
<option value="x">Annet</option>
</select>
</td>
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-2">
<input
type="date"
aria-label={`Fødselsdato for ${player.playerName}`}
className={`${CONTROL} w-40`}
value={player.birthDate ?? ""}
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
/>
<span className="min-w-12 text-xs font-semibold tabular-nums text-muted-foreground">
{age !== null ? `${age} år` : "Ukjent"}
</span>
</div>
</td>
{rounds.map((round) => {
const cell = cells[round.id]?.[player.participantId]
const assigned = cell?.assigned ?? false
const busy = busyRoundIds.includes(round.id)
return (
<td key={round.id} className="px-4 py-3 align-middle">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={assigned}
disabled={busy}
aria-label={`${player.playerName} spiller ${round.label}`}
onChange={(e) => {
const checked = e.target.checked
const teeId = checked
? defaultTees[round.id] ?? round.tees[0]?.id ?? ""
: cell?.teeId ?? round.tees[0]?.id ?? ""
onToggleRound(round.id, player.participantId, checked, teeId)
}}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="text-xs font-semibold text-muted-foreground">
{assigned ? "Spiller" : "Ikke satt"}
</span>
</label>
{assigned && (
<select
aria-label={`Utslag for ${player.playerName} i ${round.label}`}
className={`${CONTROL} w-28`}
value={cell?.teeId ?? ""}
disabled={busy}
onChange={(e) => onChangeRoundTee(round.id, player.participantId, e.target.value)}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
)}
</div>
</td>
)
})}
<td className="px-4 py-3 align-middle">
<a
href={`/organizations/${organizationId}/players?highlight=${player.playerId}`}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground underline-offset-2 transition-colors duration-200 ease-in-out hover:text-foreground hover:underline"
>
<ExternalLink aria-hidden="true" className="size-3.5" />
Rediger i spillerpoolen
</a>
</td>
<td className="px-4 py-3 text-right align-middle">
<button
type="button"
aria-label={`Fjern ${player.playerName}`}
onClick={() => onRemoveParticipant(player.participantId)}
className="inline-flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</td>
</tr>
)
}

20
frontend/i18n/request.ts Normal file
View file

@ -0,0 +1,20 @@
import { getRequestConfig } from "next-intl/server"
import { cookies } from "next/headers"
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/locale"
// Ingen URL-prefiks-routing (ADR-094) -- dagens ruter har norske slugs
// (logg-inn/velkommen/kontosammenslaing) og mange delte/bokmerkede lenker
// (offentlige turneringssider, magic-link-e-poster). Språk styres i
// stedet av en enkel cookie, satt av language-switcher.tsx eller
// locale-bootstrap.tsx (som synker inn brukerens preferred_locale første
// gang, se de filene for detaljer) -- ALDRI av URL-en.
export default getRequestConfig(async () => {
const cookieStore = await cookies()
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value
const locale = isLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})

12
frontend/lib/locale.ts Normal file
View file

@ -0,0 +1,12 @@
// i18n (ADR-094) -- delte, RENE konstanter (ingen next/headers eller
// next-intl/server-import) slik at både server-only i18n/request.ts OG
// klientkomponenter (locale-bootstrap.tsx/language-switcher.tsx) kan
// importere dem uten å dra server-only-kode inn i klientbunten.
export const SUPPORTED_LOCALES = ["nb", "en"] as const
export type Locale = (typeof SUPPORTED_LOCALES)[number]
export const DEFAULT_LOCALE: Locale = "nb"
export const LOCALE_COOKIE = "locale"
export function isLocale(value: string | undefined | null): value is Locale {
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value)
}

56
frontend/messages/en.json Normal file
View file

@ -0,0 +1,56 @@
{
"common": {
"languageSwitcher": {
"label": "Language",
"norwegian": "Norsk",
"english": "English"
}
},
"help": {
"pageTitle": "Help",
"backLabel": "Back",
"searchPlaceholder": "Search",
"searchLabel": "Search frequently asked questions",
"noResults": "No results.",
"overview": {
"title": "How the tournament module works",
"intro": "A short explanation of how a tournament fits together in TeeCup, from creation to a finished result.",
"sections": [
{
"title": "The basic idea",
"body": "Everything in TeeCup belongs to an organization (the club or organizer). Inside an organization you create tournaments. There are two types: individual (a flat field of players, no teams) and team (two teams against each other, Ryder Cup format)."
},
{
"title": "The four building blocks of an individual tournament",
"body": "Participants (who's in the tournament, drawn from the club's player pool), Rounds (how many days/rounds, and which course), Round participation (a person can be in the tournament without playing a given round -- actually playing requires a separate choice of tee), and Score (the actual strokes, recorded hole by hole)."
},
{
"title": "Setup in six steps",
"body": "Tournament setup (name, dates, visibility, registration), Rounds and courses, Classes (optional), Players (who's in, and who plays which rounds), Groups and start list (who plays together, when), and Results and options (scoring method, cut, tiebreak)."
},
{
"title": "From score to result",
"body": "You choose a scoring method (gross strokes, net strokes, Stableford, or some more specialized formats) which decides how the raw strokes become a leaderboard ranking. A cut can keep only the top finishers after a chosen round (with ties at the cutoff line let through). A tiebreak resolves a tied result -- countback, lowest handicap, a playoff, or a shared position -- and never affects where the cut line falls."
}
]
},
"faq": [
{
"question": "How does an organizer add players to the player pool?",
"answer": "Three ways: (1) From the members page, \"Edit players\" -- upload a CSV file, or \"Add row\" to enter one player manually. (2) \"Import from CSV\" directly from a tournament's Participants list -- creates the players in the pool AND registers them for the tournament in one operation. (3) Add a brand-new player on the spot while adding them as a participant in a tournament."
},
{
"question": "What's the difference between being a tournament participant and playing a round?",
"answer": "Being a participant means the person is registered for the tournament itself. Playing a round is a separate choice -- the person also needs to be assigned to that round, with a tee. A participant can therefore be in the tournament without playing every round."
},
{
"question": "What does Cut mean?",
"answer": "A cut keeps only the top finishers after a chosen round -- everyone else is out of the remaining rounds. The line is a POSITION, not a fixed headcount: if several players are tied at position N, they all make it through, so the actual number can end up slightly higher than the number you set."
},
{
"question": "What happens with a tied result (tiebreak)?",
"answer": "You choose the rule yourself -- countback (best final holes), lowest handicap, a playoff, or simply sharing the position. This can be set differently for the winner than for the rest of the field, and it never affects where the cut line falls."
}
]
}
}

56
frontend/messages/nb.json Normal file
View file

@ -0,0 +1,56 @@
{
"common": {
"languageSwitcher": {
"label": "Språk",
"norwegian": "Norsk",
"english": "English"
}
},
"help": {
"pageTitle": "Hjelp",
"backLabel": "Tilbake",
"searchPlaceholder": "Søk",
"searchLabel": "Søk i vanlige spørsmål",
"noResults": "Ingen treff.",
"overview": {
"title": "Slik fungerer turneringsmodulen",
"intro": "En kort forklaring på hvordan en turnering henger sammen i TeeCup, fra opprettelse til ferdig resultat.",
"sections": [
{
"title": "Grunnideen",
"body": "Alt i TeeCup henger på en organisasjon (klubben eller arrangøren). Inni en organisasjon oppretter du turneringer. Det finnes to typer: individuell (et flatt felt av spillere, ingen lag) og lag (to lag mot hverandre, Ryder Cup-format)."
},
{
"title": "De fire byggeklossene i en individuell turnering",
"body": "Deltakere (hvem som er med i turneringen, hentet fra klubbens spillerpool), Runder (hvor mange dager/runder, og hvilken bane), Rundedeltakelse (en person kan være med i turneringen uten å spille en gitt runde -- å faktisk spille krever et eget valg av utslagssted), og Score (de faktiske slagene, registrert hull for hull)."
},
{
"title": "Oppsett i seks steg",
"body": "Turneringsoppsett (navn, datoer, synlighet, påmelding), Runder og baner, Klasser (valgfritt), Spillere (hvem er med, og hvem spiller hvilke runder), Grupper og startliste (hvem spiller sammen, når), og Resultat og opsjoner (scoringsmetode, cut, tiebreak)."
},
{
"title": "Fra score til resultat",
"body": "Du velger en scoringsmetode (bruttoslag, nettoslag, Stableford, eller mer spesielle formater) som avgjør hvordan de rå slagene blir til en rangering på leaderboardet. Cut kan beholde kun de øverste etter en valgt runde (med delt plass rundt grensen). Tiebreak avgjør likt resultat -- countback, laveste handicap, omspill, eller delt plassering -- og påvirker aldri hvor cut-grensen går."
}
]
},
"faq": [
{
"question": "Hvordan legger jeg til spillere i spillerpoolen?",
"answer": "På tre måter: (1) Fra medlemssiden, \"Rediger spillere\" -- last opp en CSV-fil, eller \"Legg til rad\" for å legge inn én spiller manuelt. (2) \"Importer fra CSV\" direkte fra en turnerings Deltakere-liste -- oppretter spillerne i poolen OG melder dem på turneringen i samme operasjon. (3) Legg til en helt ny spiller på stedet mens du legger dem til som deltaker i en turnering."
},
{
"question": "Hva er forskjellen på å være deltaker i turneringen og å spille en runde?",
"answer": "Å være deltaker betyr at personen er meldt på selve turneringen. Å spille en runde er et eget valg -- personen må i tillegg tilordnes den runden, med et utslagssted. En deltaker kan altså være med i turneringen uten å spille alle rundene."
},
{
"question": "Hva betyr Cut?",
"answer": "Cut beholder kun de øverste plasseringene etter en valgt runde -- resten er ute av videre spill. Grensen er en PLASSERING, ikke et fast antall: er flere spillere likt med nr. N, slipper alle disse gjennom, så det kan bli litt flere enn tallet du satte."
},
{
"question": "Hva skjer ved likt resultat (tiebreak)?",
"answer": "Du velger selv regelen -- countback (beste siste hull), laveste handicap, omspill, eller at plasseringen rett og slett deles. Dette kan settes forskjellig for vinneren enn for resten av feltet, og påvirker aldri hvor cut-grensen går."
}
]
}
}

View file

@ -1,8 +1,14 @@
import createNextIntlPlugin from "next-intl/plugin"
// API-et proxyes server-side under samme opprinnelse (ingen CORS, cookien // API-et proxyes server-side under samme opprinnelse (ingen CORS, cookien
// fungerer uendret) -- se ADR-009/015. TEECUP_API_ORIGIN peker mot en lokal // fungerer uendret) -- se ADR-009/015. TEECUP_API_ORIGIN peker mot en lokal
// backend i dev; i prod peker den mot teecup_api på det delte Docker-nettverket. // backend i dev; i prod peker den mot teecup_api på det delte Docker-nettverket.
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000" const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
// i18n (ADR-094) -- leser i18n/request.ts, INGEN URL-routing (se den filens
// egen kommentar for hvorfor).
const withNextIntl = createNextIntlPlugin("./i18n/request.ts")
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
@ -64,4 +70,4 @@ const nextConfig = {
}, },
} }
export default nextConfig export default withNextIntl(nextConfig)

View file

@ -17,6 +17,7 @@
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"mapbox-gl": "3.27.0", "mapbox-gl": "3.27.0",
"next": "16.2.6", "next": "16.2.6",
"next-intl": "^4.13.7",
"papaparse": "^5.6.0", "papaparse": "^5.6.0",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",

View file

@ -29,6 +29,9 @@ importers:
next: next:
specifier: 16.2.6 specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl:
specifier: ^4.13.7
version: 4.13.7(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.7.3)
papaparse: papaparse:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0 version: 5.6.0
@ -267,6 +270,18 @@ packages:
'@floating-ui/utils@0.2.12': '@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@formatjs/fast-memoize@3.1.7':
resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==}
'@formatjs/icu-messageformat-parser@3.5.17':
resolution: {integrity: sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==}
'@formatjs/icu-skeleton-parser@2.1.11':
resolution: {integrity: sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==}
'@formatjs/intl-localematcher@0.8.13':
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
'@hono/node-server@2.1.0': '@hono/node-server@2.1.0':
resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -522,6 +537,88 @@ packages:
'@oxc-project/types@0.143.0': '@oxc-project/types@0.143.0':
resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
'@parcel/watcher-android-arm64@2.6.0':
resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.6.0':
resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.6.0':
resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.6.0':
resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.6.0':
resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.6.0':
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.6.0':
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.6.0':
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.6.0':
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.6.0':
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.6.0':
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-x64@2.6.0':
resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.6.0':
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'}
'@rolldown/binding-android-arm64@1.2.3': '@rolldown/binding-android-arm64@1.2.3':
resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -615,6 +712,9 @@ packages:
'@rolldown/pluginutils@1.0.1': '@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@sec-ant/readable-stream@0.4.1': '@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@ -625,9 +725,102 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/core-darwin-arm64@1.15.47':
resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.47':
resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.47':
resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.47':
resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.47':
resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.47':
resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.47':
resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.47':
resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.47':
resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.47':
resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.47':
resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.47':
resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.47':
resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/types@0.1.28':
resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
'@tailwindcss/node@4.3.3': '@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@ -1255,6 +1448,9 @@ packages:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
icu-minify@4.13.7:
resolution: {integrity: sha512-X9gLFtipsP4HHbmy9urh+palImTR9P6lyhvmgbP6iym8i0IwhcsS4Z6KMjkEoCU6O16OJT5JIZkd8xfDROYo/A==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -1266,6 +1462,9 @@ packages:
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
intl-messageformat@11.2.14:
resolution: {integrity: sha512-9f2VD1HFuxUvMw0RxsaP8WmMns6JRTnsNB/zghTFrp11ZktiXWwVDeZBPQchBKEmo+Gx/ZhxI7Qht7YglFD4PA==}
ip-address@10.4.0: ip-address@10.4.0:
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@ -1644,6 +1843,19 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
next-intl-swc-plugin-extractor@4.13.7:
resolution: {integrity: sha512-MxOUMGKncc/D6rofu0O80I6Ebr3gqlH8XiEb0Uu5TZmX7DqY3NVHs70Uy4bvtgWmHeDwsra6KU8EBtfRVGRv7Q==}
next-intl@4.13.7:
resolution: {integrity: sha512-j7KnGWt4Ih6TnW1x714R8bX3H+DYP25fqLTYTfUzAFXh0Od57WuQYM/Sf58yalvIXhE6y8sBYHlrCFmr0jPy3g==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
next@16.2.6: next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
@ -1665,6 +1877,9 @@ packages:
sass: sass:
optional: true optional: true
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.53: node-releases@2.0.53:
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1791,6 +2006,9 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'} engines: {node: '>=8'}
po-parser@2.2.0:
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
postcss-selector-parser@7.1.4: postcss-selector-parser@7.1.4:
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -2106,6 +2324,11 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
use-intl@4.13.7:
resolution: {integrity: sha512-vWapep/2GESovKEmkxaG1Bkt6AWwANCWrM4kwOYbMmvQ4IsBGJFx9l66h8DNCcU0jSOzNtSFyRXFcYvgAak/Cg==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-sync-external-store@1.6.0: use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies: peerDependencies:
@ -2505,6 +2728,18 @@ snapshots:
'@floating-ui/utils@0.2.12': {} '@floating-ui/utils@0.2.12': {}
'@formatjs/fast-memoize@3.1.7': {}
'@formatjs/icu-messageformat-parser@3.5.17':
dependencies:
'@formatjs/icu-skeleton-parser': 2.1.11
'@formatjs/icu-skeleton-parser@2.1.11': {}
'@formatjs/intl-localematcher@0.8.13':
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@hono/node-server@2.1.0(hono@4.13.0)': '@hono/node-server@2.1.0(hono@4.13.0)':
dependencies: dependencies:
hono: 4.13.0 hono: 4.13.0
@ -2687,6 +2922,62 @@ snapshots:
'@oxc-project/types@0.143.0': {} '@oxc-project/types@0.143.0': {}
'@parcel/watcher-android-arm64@2.6.0':
optional: true
'@parcel/watcher-darwin-arm64@2.6.0':
optional: true
'@parcel/watcher-darwin-x64@2.6.0':
optional: true
'@parcel/watcher-freebsd-x64@2.6.0':
optional: true
'@parcel/watcher-linux-arm-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-arm-musl@2.6.0':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-arm64-musl@2.6.0':
optional: true
'@parcel/watcher-linux-x64-glibc@2.6.0':
optional: true
'@parcel/watcher-linux-x64-musl@2.6.0':
optional: true
'@parcel/watcher-win32-arm64@2.6.0':
optional: true
'@parcel/watcher-win32-x64@2.6.0':
optional: true
'@parcel/watcher@2.6.0':
dependencies:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.5
optionalDependencies:
'@parcel/watcher-android-arm64': 2.6.0
'@parcel/watcher-darwin-arm64': 2.6.0
'@parcel/watcher-darwin-x64': 2.6.0
'@parcel/watcher-freebsd-x64': 2.6.0
'@parcel/watcher-linux-arm-glibc': 2.6.0
'@parcel/watcher-linux-arm-musl': 2.6.0
'@parcel/watcher-linux-arm64-glibc': 2.6.0
'@parcel/watcher-linux-arm64-musl': 2.6.0
'@parcel/watcher-linux-x64-glibc': 2.6.0
'@parcel/watcher-linux-x64-musl': 2.6.0
'@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0
'@rolldown/binding-android-arm64@1.2.3': '@rolldown/binding-android-arm64@1.2.3':
optional: true optional: true
@ -2731,16 +3022,78 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {} '@rolldown/pluginutils@1.0.1': {}
'@schummar/icu-type-parser@1.21.5': {}
'@sec-ant/readable-stream@0.4.1': {} '@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@swc/core-darwin-arm64@1.15.47':
optional: true
'@swc/core-darwin-x64@1.15.47':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.47':
optional: true
'@swc/core-linux-arm64-gnu@1.15.47':
optional: true
'@swc/core-linux-arm64-musl@1.15.47':
optional: true
'@swc/core-linux-ppc64-gnu@1.15.47':
optional: true
'@swc/core-linux-s390x-gnu@1.15.47':
optional: true
'@swc/core-linux-x64-gnu@1.15.47':
optional: true
'@swc/core-linux-x64-musl@1.15.47':
optional: true
'@swc/core-win32-arm64-msvc@1.15.47':
optional: true
'@swc/core-win32-ia32-msvc@1.15.47':
optional: true
'@swc/core-win32-x64-msvc@1.15.47':
optional: true
'@swc/core@1.15.47':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.28
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.47
'@swc/core-darwin-x64': 1.15.47
'@swc/core-linux-arm-gnueabihf': 1.15.47
'@swc/core-linux-arm64-gnu': 1.15.47
'@swc/core-linux-arm64-musl': 1.15.47
'@swc/core-linux-ppc64-gnu': 1.15.47
'@swc/core-linux-s390x-gnu': 1.15.47
'@swc/core-linux-x64-gnu': 1.15.47
'@swc/core-linux-x64-musl': 1.15.47
'@swc/core-win32-arm64-msvc': 1.15.47
'@swc/core-win32-ia32-msvc': 1.15.47
'@swc/core-win32-x64-msvc': 1.15.47
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@swc/types@0.1.28':
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/node@4.3.3': '@tailwindcss/node@4.3.3':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@ -3335,6 +3688,10 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
icu-minify@4.13.7:
dependencies:
'@formatjs/icu-messageformat-parser': 3.5.17
ignore@5.3.2: {} ignore@5.3.2: {}
import-fresh@3.3.1: import-fresh@3.3.1:
@ -3344,6 +3701,11 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
intl-messageformat@11.2.14:
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@formatjs/icu-messageformat-parser': 3.5.17
ip-address@10.4.0: {} ip-address@10.4.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@ -3595,6 +3957,25 @@ snapshots:
negotiator@1.0.0: {} negotiator@1.0.0: {}
next-intl-swc-plugin-extractor@4.13.7: {}
next-intl@4.13.7(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@5.7.3):
dependencies:
'@formatjs/intl-localematcher': 0.8.13
'@parcel/watcher': 2.6.0
'@swc/core': 1.15.47
icu-minify: 4.13.7
negotiator: 1.0.0
next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl-swc-plugin-extractor: 4.13.7
po-parser: 2.2.0
react: 19.2.8
use-intl: 4.13.7(react@19.2.8)
optionalDependencies:
typescript: 5.7.3
transitivePeerDependencies:
- '@swc/helpers'
next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies: dependencies:
'@next/env': 16.2.6 '@next/env': 16.2.6
@ -3619,6 +4000,8 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
node-addon-api@7.1.1: {}
node-releases@2.0.53: {} node-releases@2.0.53: {}
npm-run-path@4.0.1: npm-run-path@4.0.1:
@ -3732,6 +4115,8 @@ snapshots:
dependencies: dependencies:
find-up: 3.0.0 find-up: 3.0.0
po-parser@2.2.0: {}
postcss-selector-parser@7.1.4: postcss-selector-parser@7.1.4:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@ -4105,6 +4490,14 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
use-intl@4.13.7(react@19.2.8):
dependencies:
'@formatjs/fast-memoize': 3.1.7
'@schummar/icu-type-parser': 1.21.5
icu-minify: 4.13.7
intl-messageformat: 11.2.14
react: 19.2.8
use-sync-external-store@1.6.0(react@19.2.8): use-sync-external-store@1.6.0(react@19.2.8):
dependencies: dependencies:
react: 19.2.8 react: 19.2.8

View file

@ -1,2 +1,4 @@
allowBuilds: allowBuilds:
'@parcel/watcher': set this to true or false
'@swc/core': set this to true or false
sharp: true sharp: true

View file

@ -0,0 +1,40 @@
"""
preferred_locale kunne FØR kun settes ved førstegangs-signup (ADR-015)
-- aldri endres etterpå. ADR-094 la feltet til den allerede generiske
ProfileUpdate (PATCH /profile) slik at language-switcher.tsx kan endre
det når som helst."""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.auth import ProfileUpdate, update_profile
from tests.conftest import create_user
import app.db as app_db
async def test_preferred_locale_can_be_changed_after_signup(pool):
user_id = await create_user()
out = await update_profile(
ProfileUpdate(preferred_locale="en"),
user=CurrentUser(user_id=user_id),
)
assert out.preferred_locale == "en"
async with app_db.plain_connection() as conn:
row = await conn.fetchrow("SELECT preferred_locale FROM app_user WHERE id = $1", user_id)
assert row["preferred_locale"] == "en"
async def test_invalid_locale_is_rejected(pool):
with pytest.raises(Exception):
ProfileUpdate(preferred_locale="fr")
async def test_updating_other_fields_leaves_locale_unchanged(pool):
user_id = await create_user()
await update_profile(ProfileUpdate(preferred_locale="en"), user=CurrentUser(user_id=user_id))
out = await update_profile(ProfileUpdate(bio="Elsker links-golf"), user=CurrentUser(user_id=user_id))
assert out.preferred_locale == "en"