Ja, jeg forsto rekkefølgen — steg 1 og 2 er nå ferdig og scratch-verifisert:

Steg 1 (backend, HCP-prosent + Match-HCP): ny migrasjon 051_round_allowance_override.sql (round.allowance_override jsonb), koblet inn i _recompute_side_handicaps/_relative_strokes_for_round + POST/PATCH /rounds. Verifisert presist i scratch: 100%→50%-prosent ga playing_handicap 24/10 → 12/5 nøyaktig som beregnet for hånd, og use_matchplay_handicap slått av ga rå 12/5 i stedet for differensial 7/0. Slagspill upåvirket (regresjon bekreftet).

Steg 2 (kode-fiks, lag-gruppering): ScorecardGrid (live-scoring) og MatchScorecardGrid (post-runde/fremtidig sammenslått side) sorterer nå stabilt på round_side_id før rendering. Bygget en fersk scratch-fourball-runde med akkurat ditt bug-scenario (A→Rødt, B→Blått, C→Rødt, D→Blått, lagt til i den rekkefølgen) og bekreftet visuelt i nettleseren i begge visningene: Rødt lag vises samlet, deretter Blått — ikke lenger interleaved.

Typesjekk + full produksjonsbuild kompilerte rent gjennom hele runden.

Gjenstår før jeg går videre til V0-promptene: migrasjon 051 er kun kjørt i scratch, ikke mot ekte teecup_db. Planen er:

docker exec -i teeoff_db psql -U teeoff_admin -d teecup_db -v ON_ERROR_STOP=1 < 051_round_allowance_override.sql
— ett rent additivt ALTER TABLE round ADD COLUMN allowance_override jsonb, ingen eksisterende rader berøres. Deretter docker compose up -d --build teecup_api teecup_frontend (backend for allowance-koblingen, frontend for gruppering-fiksen).

Bekreft så kjører jeg dette, og går videre til V0-prompt 1 (rundeoppsett-veiviseren).
This commit is contained in:
Erol Haagenrud 2026-08-02 08:24:01 +02:00
parent 27bff38afe
commit 3da637b7a2
9 changed files with 324 additions and 31 deletions

View file

@ -390,7 +390,9 @@
"Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/2fa/setup/confirm -H 'Content-Type: application/json' -d '{\"method\":\"totp\",\"code\":\"682645\",\"secret\":\"H5FZIL7SO5BYHP3AQEFZPS6O7WR4ZHG5\"}')",
"Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/logout)",
"Bash(rm -f /opt/teecup/v0-zip22-login-*.png /opt/teecup/v0-integrated-*.png *)",
"Bash(curl -s -o /dev/null -w \"friends/on-course anonym: %{http_code}\\\\n\" https://teecup.teeoff.no/friends/on-course)"
"Bash(curl -s -o /dev/null -w \"friends/on-course anonym: %{http_code}\\\\n\" https://teecup.teeoff.no/friends/on-course)",
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch >/dev/null 2>&1 *)",
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -0,0 +1,16 @@
-- =====================================================================
-- TeeCup — round.allowance_override, migrasjon 051.
--
-- "Det store grepet" (2026-08-01/02): brukeren bekreftet at HCP-prosenten
-- for frittstående runders match-play-formater alltid skal være
-- justerbar, og at Match-HCP (differensial-fordeling mellom sidene, jf.
-- handicap_engine.py sin allerede testede match_play_strokes()) skal
-- kunne slås av/på. ADR-014s "fire brytere" (AllowanceConfig) og selve
-- match_play_strokes()-algoritmen var allerede ferdigbygd og produksjons-
-- kjørt for org-turneringer (session.allowance_override, migrasjon 001)
-- -- frittstående runder manglet bare selve kolonnen. Ingen ny motor,
-- ren gjenbruk (se app/handicap.py sin parse_allowance_config).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round ADD COLUMN allowance_override jsonb;

View file

@ -32,6 +32,7 @@ fremtidig turnering-kilde, ADR-038 Beslutning E).
from __future__ import annotations
import json
import math
import uuid
from datetime import date, datetime, timedelta
@ -443,9 +444,20 @@ async def _resolve_round_course(round_row) -> _ResolvedCourse:
# (org-scopede turnering-matcher) til frittstående runder. SAMME motor
# (handicap_engine.py), samme allowance-familie -- ingen ny regnelogikk
# for match/fourball/foursome/greensome/scramble, kun andre tabeller.
# Ingen allowance_override-støtte i v1 (kun DEFAULT_MATCHPLAY_ALLOWANCES).
#
# round.allowance_override (migrasjon 051, "det store grepet" 2026-08-02):
# HCP-prosenten (strategy) er alltid justerbar, og Match-HCP (use_matchplay_
# handicap -- differensial-fordeling mellom sidene) kan slås av/på, per
# brukerens eksplisitte bekreftelse. Hentes friskt fra `round`-raden ved
# hvert kall, samme mønster som scoring.py/matches.py sin session.
# allowance_override -- ikke cachet i selve config-objektet.
# ---------------------------------------------------------------------------
async def _round_allowance_override(conn, round_id: str) -> dict | None:
raw = await conn.fetchval("SELECT allowance_override::text FROM round WHERE id = $1", round_id)
return json.loads(raw) if raw else None
async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> None:
"""Beregn og lagre round_participant.playing_handicap for begge sider.
@ -454,7 +466,7 @@ async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> No
en egen, ny kolonne kun for den format-relative match-play-enheten.
"""
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
config = parse_allowance_config(engine_format, None)
config = parse_allowance_config(engine_format, await _round_allowance_override(conn, round_id))
expected = _SIDE_PLAYER_COUNT[play_format]
sides = await conn.fetch("SELECT id::text AS id FROM round_side WHERE round_id = $1", round_id)
@ -492,7 +504,7 @@ async def _relative_strokes_for_round(conn, round_id: str, play_format: str) ->
for én eller flere enheter ennå.
"""
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
config = parse_allowance_config(engine_format, None)
config = parse_allowance_config(engine_format, await _round_allowance_override(conn, round_id))
if engine_format in SIDE_IS_UNIT:
rows = await conn.fetch(
@ -712,6 +724,16 @@ class RoundCreate(BaseModel):
# Frontend forhåndsutfyller denne til True når play_format='match'
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
exclude_owner_from_handicap: bool = False
# HCP-prosent + Match-HCP, migrasjon 051 ("det store grepet",
# 2026-08-02) -- ADR-014s "fire brytere", samme JSON-form som org-
# turneringers session.allowance_override (`{use_handicap,
# use_course_handicap, use_matchplay_handicap, strategy:{type,
# percentage|...}}`), se app/handicap.py sin parse_allowance_config.
# Manglende nøkler = standardverdi for formatet. Gir kun mening for
# to-sidede formater (match/fourball/foursome/greensome/scramble/
# chapman/shamble/money_ball/high_low_high) -- ingen validering her
# avviser den for andre, siden den da rett og slett aldri leses.
allowance_override: dict | None = None
# KUN meningsfulle (og påkrevd) når play_format='skins' -- ADR-039
# Beslutning D. Brukeren som setter opp runden velger begge uavhengig.
skins_scoring: Literal["net", "gross"] | None = None
@ -842,6 +864,8 @@ class RoundOut(BaseModel):
visible_categories: list[str]
# Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
flight_group_id: str | None
# HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard.
allowance_override: dict | None
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
@ -850,7 +874,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
bbb_sweep_bonus_enabled, shamble_best_n
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override::text AS allowance_override
FROM round WHERE id = $1
""",
round_id,
@ -972,6 +996,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
visibility_mode=round_row["visibility_mode"],
visible_categories=[r["category"] for r in visible_category_rows],
flight_group_id=round_row["flight_group_id"],
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
)
@ -1096,8 +1121,8 @@ async def create_round(
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name, play_format,
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id,
bbb_sweep_bonus_enabled, shamble_best_n)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20::jsonb)
RETURNING id::text AS id
""",
user.user_id,
@ -1119,6 +1144,7 @@ async def create_round(
body.flight_group_id,
body.bbb_sweep_bonus_enabled,
body.shamble_best_n,
json.dumps(body.allowance_override) if body.allowance_override is not None else None,
)
round_id = round_row["id"]
@ -1827,6 +1853,12 @@ class RoundUpdate(BaseModel):
# legges til (round-detail.tsx). Ingen vei til å FJERNE en runde fra en
# gruppe igjen i denne runden -- ikke etterspurt.
flight_group_id: str | None = None
# HCP-prosent + Match-HCP (migrasjon 051) -- samme "None = ikke rør"-
# konvensjon som resten av denne modellen (ingen vei til å eksplisitt
# NULLSTILLE tilbake til formatets standard i v1 -- ikke etterspurt).
# Rører ALDRI course_handicap_snapshot, men regner playing_handicap på
# nytt for allerede tildelte sider (se update_round).
allowance_override: dict | None = None
@router.patch("/rounds/{round_id}", response_model=RoundOut)
@ -1858,7 +1890,9 @@ async def update_round(
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
round_row = await conn.fetchrow(
"SELECT started_at, completed_at, play_format FROM round WHERE id = $1", round_id
)
is_completed = round_row["completed_at"] is not None
if is_completed and (body.course_source is not None or body.holes_planned is not None):
@ -1926,6 +1960,12 @@ async def update_round(
)
if body.play_format is not None:
await conn.execute("UPDATE round SET play_format = $2 WHERE id = $1", round_id, body.play_format)
if body.allowance_override is not None:
await conn.execute(
"UPDATE round SET allowance_override = $2::jsonb WHERE id = $1",
round_id,
json.dumps(body.allowance_override),
)
if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None:
@ -1984,6 +2024,16 @@ async def update_round(
p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number],
)
# HCP-prosent/Match-HCP-endring (migrasjon 051) -- regn playing_
# handicap på nytt for allerede tildelte sider, samme mønster som
# tournaments.py sin _recompute_session_matches ved allowance-
# bytte. Kun meningsfullt for to-sidede formater (round_side
# finnes ikke for andre -- harmløst no-op der uansett).
if body.allowance_override is not None:
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
if effective_play_format in _TWO_SIDED_FORMATS:
await _recompute_side_handicaps(conn, round_id, effective_play_format)
result = await _load_round_out(conn, round_id, user.user_id)
await broadcast_round_update(round_id)

View file

@ -0,0 +1,5 @@
import { MoreMenu } from "@/components/more-menu"
export default function MorePage() {
return <MoreMenu />
}

View file

@ -31,14 +31,18 @@ import {
ArrowRight,
Bell,
Building2,
CalendarDays,
ChevronRight,
Flag,
Home,
KeyRound,
LogOut,
MapPin,
MoreHorizontal,
Trophy,
TrendingDown,
TrendingUp,
User,
UserCircle,
UserPlus,
X,
@ -58,26 +62,42 @@ import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// Låst "Forest Green"-palett (2026-08-01) -- samme sett som login-form.tsx,
// se DESIGN_SYSTEM.md sitt notat om denne runden. ALDRI #84b089/#a4a5a5 som
// tekst på mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål (7:1).
// Låst "Forest Green"-palett -- v2 (2026-08-01, dashbord-fargekorrigering
// mot Stitch sitt faktiske skjermbilde). ALDRI #84b089/#a4a5a5 som tekst på
// mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål. `primary` er
// bevisst IKKE Stitch sin rå pikselverdi (#2d950c, som målte kun 3,88:1 med
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
const C = {
bg: "#f7faf8",
card: "#ffffff",
glass: "rgba(255, 255, 255, 0.78)",
ink: "#012c11",
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
inkOn: "#ffffff",
accent: "#1a4325",
primary: "#1f6b08", // lys, innbydende CTA-grønn -- hvit tekst/ikon (~6,6:1)
primaryInk: "#ffffff",
accent: "#1a4325", // mørkegrønn aksent (installer-banner-gradient)
accentMuted: "#bfeec4",
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941",
border: "#c1c9bf",
borderSoft: "#e4ebe3", // lysere strek for flatere/lysere overflater (Venner på banen)
tint: "#edf3ef",
warmBg: "#ffedd5",
warmInk: "#9a3412",
} as const
// Kategoriske pastellfarger for "Venner på banen" -- én tone per person, i
// stedet for én ensfarget tone. Hver par en lys bakgrunn med en mørk,
// kontrastrik tekst-tone for initialene.
const AVATAR_HUES = [
{ bg: "#dbeafe", ink: "#1e40af" }, // lys blå
{ bg: "#ffedd5", ink: "#9a3412" }, // lys oransje
{ bg: "#dcfce7", ink: "#166534" }, // lys grønn
{ bg: "#fce7f3", ink: "#9d174d" }, // lys rosa
] as const
// --- API-typer ---------------------------------------------------------------
type MyOrg = { organization_id: string; name: string; role: string }
@ -452,13 +472,13 @@ export function Dashboard() {
return (
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
<header
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur-xl"
style={{ backgroundColor: C.glass, borderColor: C.border }}
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)]"
style={{ backgroundColor: C.bg, borderColor: C.border }}
>
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Link href="/dashboard" aria-label="TeeCup til forsiden" className="flex items-center gap-2 rounded-xl">
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.ink }}>
<Flag aria-hidden="true" className="size-5" fill="#f79b3d" color="#f79b3d" />
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.primary }}>
<Flag aria-hidden="true" className="size-5" fill={C.successBg} color={C.successBg} />
</span>
<span className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
TeeCup
@ -487,7 +507,11 @@ export function Dashboard() {
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
{/* Bunn-fanerad (2026-08-01) er fixed/56px + safe-area -- pb-24 gir
<main> nok bunnplass til at "Statistikk" ikke skjules bak den
(reelt overlapp funnet under browserverifisering av zip 24, samme
klasse feil som å glemme at en fixed bar tar plass fra layouten). */}
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-balance" style={{ color: C.ink }}>
God dag, {me.first_name ?? me.display_name}
@ -527,10 +551,59 @@ export function Dashboard() {
<OrganizationsFooter organizations={me.organizations} />
</div>
</main>
<BottomTabBar active="home" />
</div>
)
}
// Fast bunn-fanerad (2026-08-01, brukerens eget ønske etter å ha sett
// Stitch sin versjon) -- app-bred navigasjon, vist på ALLE skjermer som
// importerer denne. hrefs er bevisst RETTET fra V0 sitt forslag (/rounds,
// /tournaments) til de faktiske frontend-rutene -- /rounds er reservert
// API-prefiks (samme kollisjonsklasse som /rounds->/my-rounds tidligere i
// prosjektet), og det finnes ingen egen "/tournaments"-liste-side, kun
// /tournaments/[id]. "Turneringer" scroller derfor til dashbordets egen
// "Kommende turneringer"-seksjon i stedet for å lenke til en side som ikke
// finnes. "Mer" peker til en ny, minimal samleside (se app/more/page.tsx).
type TabKey = "home" | "rounds" | "tournaments" | "profile" | "more"
export function BottomTabBar({ active }: { active: TabKey }) {
const tabs: { key: TabKey; icon: typeof Flag; label: string; href: string }[] = [
{ key: "home", icon: Home, label: "Hjem", href: "/dashboard" },
{ key: "rounds", icon: CalendarDays, label: "Runder", href: "/my-rounds" },
{ key: "tournaments", icon: Trophy, label: "Turneringer", href: "/dashboard#kommende-turneringer" },
{ key: "profile", icon: User, label: "Profil", href: "/account" },
{ key: "more", icon: MoreHorizontal, label: "Mer", href: "/more" },
]
return (
<nav
aria-label="Hovednavigasjon"
className="fixed inset-x-0 bottom-0 z-20 border-t"
style={{ backgroundColor: C.card, borderColor: C.border, paddingBottom: "env(safe-area-inset-bottom)" }}
>
<ul className="mx-auto flex w-full max-w-2xl items-stretch justify-around px-1">
{tabs.map(({ key, icon: Icon, label, href }) => {
const isActive = key === active
return (
<li key={key} className="flex-1">
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className="flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-center transition-colors focus-visible:outline-none focus-visible:ring-2"
style={{ color: isActive ? C.primary : C.muted, outlineColor: C.accent }}
>
<Icon aria-hidden="true" className="size-6" strokeWidth={isActive ? 2.4 : 2} />
<span className="text-[11px] font-bold leading-none">{label}</span>
</Link>
</li>
)
})}
</ul>
</nav>
)
}
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
// /rounds->/my-rounds og /friends->/my-friends).
@ -551,7 +624,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
<span
aria-hidden="true"
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.glass}` }}
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.bg}` }}
>
{badgeText}
</span>
@ -600,15 +673,15 @@ function QuickAction({
onClick?: () => void
}) {
const style: React.CSSProperties = {
backgroundColor: C.ink,
color: C.inkOn,
boxShadow: "0 14px 30px -20px rgba(1, 44, 17, 0.7)",
backgroundColor: C.primary,
color: C.primaryInk,
boxShadow: "0 14px 30px -20px rgba(31, 107, 8, 0.55)",
}
const className =
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 text-center transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
const inner = (
<>
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.successBg} />
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.primaryInk} />
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</>
)
@ -864,7 +937,7 @@ function ShortcutButton({
}) {
const style: React.CSSProperties =
variant === "primary"
? { backgroundColor: C.ink, color: C.inkOn }
? { backgroundColor: C.primary, color: C.primaryInk }
: { backgroundColor: C.card, color: C.ink, border: `1px solid ${C.border}` }
const className =
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
@ -908,20 +981,21 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
<SectionHeader title="Venner på banen" />
{friends.length > 0 ? (
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
{friends.map((friend) => {
{friends.map((friend, i) => {
const playing = friend.status === "playing"
const hue = AVATAR_HUES[i % AVATAR_HUES.length]
return (
<Link
key={friend.round_id}
href={`/my-rounds/${friend.round_id}`}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-colors hover:bg-black/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: C.card, border: `1px solid ${C.borderSoft}` }}
>
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<span
className="flex size-11 items-center justify-center rounded-full text-sm font-bold tabular-nums"
style={{ backgroundColor: C.tint, color: C.ink }}
style={{ backgroundColor: hue.bg, color: hue.ink }}
>
{friendOnCourseInitials(friend.friend_name)}
</span>
@ -1004,7 +1078,7 @@ function UpcomingTournaments({
onJoinWithCode: () => void
}) {
return (
<section aria-label="Kommende turneringer">
<section id="kommende-turneringer" aria-label="Kommende turneringer" className="scroll-mt-20">
<SectionHeader title="Kommende turneringer" />
{entries.length > 0 ? (
<div className="flex flex-col gap-3">

View file

@ -0,0 +1,118 @@
"use client"
// "Mer"-siden (2026-08-01) -- den femte fanen i den nye bunn-navigasjonen
// (dashboard.tsx sin BottomTabBar) trengte et reelt mål, ikke en lenke til
// en side som ikke fantes. Samler destinasjoner som allerede finnes andre
// steder i appen (header, dashbordets organisasjon-fot) på ett sted, i
// stedet for å bygge noe nytt -- en ren navigasjons-samleside.
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "lucide-react"
import { BottomTabBar } from "@/components/dashboard"
const C = {
bg: "#f7faf8",
card: "#ffffff",
ink: "#012c11",
muted: "#424941",
border: "#c1c9bf",
primary: "#1f6b08",
} as const
type MyOrg = { organization_id: string; name: string; role: string }
type Me = { display_name: string; first_name: string | null; organizations: MyOrg[] }
export function MoreMenu() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(null)
useEffect(() => {
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: Me | null) => setMe(data))
.catch(() => {})
}, [])
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/")
}
}
return (
<div className="flex min-h-[100dvh] flex-col" style={{ backgroundColor: C.bg }}>
<header className="border-b pt-[env(safe-area-inset-top)]" style={{ borderColor: C.border }}>
<div className="mx-auto w-full max-w-2xl px-5 py-4">
<h1 className="text-xl font-extrabold tracking-tight" style={{ color: C.ink }}>
Mer
</h1>
{me && (
<p className="text-sm" style={{ color: C.muted }}>
Innlogget som {me.first_name ?? me.display_name}
</p>
)}
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 pb-24 pt-4">
<ul
className="flex flex-col overflow-hidden rounded-2xl"
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card }}
>
<MenuLink href="/account" icon={UserCircle} label="Konto" />
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} />
{me?.organizations.map((o, i) => (
<MenuLink
key={o.organization_id}
href={`/organizations/${o.organization_id}/members?name=${encodeURIComponent(o.name)}`}
icon={Building2}
label={o.name}
last={i === me.organizations.length - 1}
/>
))}
</ul>
<button
type="button"
onClick={handleLogout}
className="mt-6 flex min-h-[52px] w-full items-center justify-center gap-2 rounded-2xl text-sm font-bold"
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card, color: C.ink }}
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</main>
<BottomTabBar active="more" />
</div>
)
}
function MenuLink({
href,
icon: Icon,
label,
last,
}: {
href: string
icon: typeof Bell
label: string
last?: boolean
}) {
return (
<li style={last ? undefined : { borderBottom: `1px solid ${C.border}` }}>
<Link href={href} className="flex min-h-[56px] items-center gap-3 px-4">
<Icon aria-hidden="true" className="size-5" color={C.primary} />
<span className="flex-1 truncate text-base font-semibold" style={{ color: C.ink }}>
{label}
</span>
<ChevronRight aria-hidden="true" className="size-5" color={C.muted} />
</Link>
</li>
)
}

View file

@ -4560,6 +4560,15 @@ function ChangeCourseForm({
// HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv
// spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn.
// Sorteringsnøkkel for lagvis gruppering (2026-08-02) -- side A (sides[0])
// først, side B (sides[1]) deretter, ingen/ukjent side sist. Brukt med en
// STABIL sort, så original rekkefølge innad i hver gruppe bevares.
function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
if (roundSideId === sides[0]?.id) return 0
if (roundSideId === sides[1]?.id) return 1
return 2
}
function ScorecardGrid({
players,
holesByParticipant,
@ -4630,6 +4639,17 @@ function ScorecardGrid({
return p.roundSideId === sides[0]?.id ? "a" : "b"
}
// Lagvis gruppering (2026-08-02, "det store grepet") -- et to-sidet
// format (fourball/match/foursome osv.) skal vise side A samlet, deretter
// side B samlet, ikke ren innsettingsrekkefølge (A, B, C, D uansett hvem
// som faktisk er lagkamerater). Stabil sortering bevarer original
// rekkefølge INNAD i hver gruppe -- kun selve grupperingen endres. Kun
// meningsfullt når begge sider faktisk finnes; ellers uendret rekkefølge.
const sortedPlayers =
sides.length === 2
? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
: players
return (
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="w-full border-separate border-spacing-0 text-sm">
@ -4725,7 +4745,7 @@ function ScorecardGrid({
</td>
</tr>
{players.map((player) => {
{sortedPlayers.map((player) => {
const list = holesByParticipant[player.id]
const frontSum = showSplit ? sumFor(player.id, front) : null
const backSum = showSplit ? sumFor(player.id, back) : null

View file

@ -589,6 +589,14 @@ function MatchScorecardGrid({
hcp: p.playing_handicap,
}))
// Lagvis gruppering (2026-08-02, "det store grepet") -- individuell-ball-
// formater (match/fourball) bygde tidligere rader i ren
// innsettingsrekkefølge (round.participants sin egen orden), ikke lagvis.
// Stabil sortering på side holder original rekkefølge INNAD i hver
// gruppe. Delt-ball-formater har allerede nøyaktig én rad per side
// (rows[0]="a", rows[1]="b" fra konstruksjonen over) -- no-op der.
rows.sort((a, b) => (a.side === b.side ? 0 : a.side === "a" ? -1 : 1))
function headerIdentity(side: "a" | "b"): HeaderIdentity {
const sideRows = rows.filter((r) => r.side === side)
if (sideRows.length === 1) return { name: sideRows[0].label, hcp: sideRows[0].hcp }

File diff suppressed because one or more lines are too long