diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index b969831..1b58917 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -9503,6 +9503,108 @@ denne runden -- vurdert tilstrekkelig dekket av backend-pytestene
compose build teecup_api teecup_frontend && up -d` for begge, rene
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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5ba8b1..b69be97 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12981,3 +12981,40 @@ Neste steg:
**Rullet ut 2026-08-19** -- bruker bekreftet. Ingen migrasjon.
`docker compose build teecup_api teecup_frontend && up -d` for
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.
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index 1c59f73..759338e 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -4645,3 +4645,36 @@ som selvregistrering), så importen selv trenger ikke håndtere
invitasjon -- bare opprette rader.
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.
diff --git a/app/routers/auth.py b/app/routers/auth.py
index becf8bd..8fb283b 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -926,6 +926,12 @@ class ProfileUpdate(BaseModel):
# 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.
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)
diff --git a/frontend/app/hjelp/page.tsx b/frontend/app/hjelp/page.tsx
new file mode 100644
index 0000000..117b9f1
--- /dev/null
+++ b/frontend/app/hjelp/page.tsx
@@ -0,0 +1,5 @@
+import { HelpPage } from "@/components/help-page"
+
+export default function Hjelp() {
+ return
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index ef71ee8..167ebf0 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,6 +1,9 @@
import type { Metadata, Viewport } from 'next'
import { Nunito } from 'next/font/google'
+import { NextIntlClientProvider } from 'next-intl'
+import { getLocale } from 'next-intl/server'
import { SwRegister } from '@/components/sw-register'
+import { LocaleBootstrap } from '@/components/locale-bootstrap'
import './globals.css'
const nunito = Nunito({
@@ -52,16 +55,24 @@ export const viewport: Viewport = {
],
}
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
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 (
-
+
-
- {children}
+
+
+
+ {children}
+
)
diff --git a/frontend/components/help-page.tsx b/frontend/components/help-page.tsx
new file mode 100644
index 0000000..4050953
--- /dev/null
+++ b/frontend/components/help-page.tsx
@@ -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 (
+
- Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
-
- )}
-
-
- {participants.map((p) => (
-
-
- {p.player_name}
- 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 på denne
- spillerens rad, i stedet for at organisator må lete den
- opp manuelt i en potensielt lang tabell. */}
-
-
- Rediger i spillerpoolen
-
-
- {classes.length > 0 && (
-
- )}
- {/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye
- HoleGrid/HoleStatsSheet ber om ved scoring for DENNE
- deltakeren, tournament-bredt (ikke per runde). */}
-
- {/* 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. */}
-
-
-
- ))}
-
-
- p.player_id))}
- onAddExisting={onAddParticipant}
- onAddNew={onAddNewPlayer}
- />
-
- )
-}
-
-// 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
-}) {
- 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 (
-
- )
-}
-
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
// 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
-// rundedeltakelse-tabellen (ADR-093) -- samme oppløsning som den nå
-// fjernede AssignRoundParticipantControl brukte for ett-om-gangen-
-// tilordning: deltakerens klasses standardutslag, KUN hvis det faktisk
-// finnes på den aktuelle rundens bane (klassens standardutslag kan
-// tilhøre en annen bane). Faller tilbake til rundens eget valgte
-// "Standardutslag" (lokal UI-state i round-participation-table.tsx),
-// og til slutt til det første utslaget i lista.
+// den sammenslåtte spillertabellen (ADR-093/094, tournament-players-
+// table.tsx) -- samme oppløsning som den nå fjernede
+// AssignRoundParticipantControl brukte for ett-om-gangen-tilordning:
+// deltakerens klasses standardutslag, KUN hvis det faktisk finnes på
+// den aktuelle rundens bane (klassens standardutslag kan tilhøre en
+// annen bane). Faller tilbake til rundens eget valgte "Standardutslag"
+// (lokal UI-state inni tournament-players-table.tsx), og til slutt til
+// det første utslaget i lista.
function resolveDefaultTeeId(
participant: ApiParticipant,
classes: ApiTournamentClass[],
diff --git a/frontend/components/language-switcher.tsx b/frontend/components/language-switcher.tsx
new file mode 100644
index 0000000..8e98b24
--- /dev/null
+++ b/frontend/components/language-switcher.tsx
@@ -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 = { nb: t("norwegian"), en: t("english") }
+
+ return (
+