diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7e7d444..419bb0a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/051_round_allowance_override.sql b/051_round_allowance_override.sql new file mode 100644 index 0000000..604fd31 --- /dev/null +++ b/051_round_allowance_override.sql @@ -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; diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 5e023b1..2ee788a 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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) diff --git a/frontend/app/more/page.tsx b/frontend/app/more/page.tsx new file mode 100644 index 0000000..17592fe --- /dev/null +++ b/frontend/app/more/page.tsx @@ -0,0 +1,5 @@ +import { MoreMenu } from "@/components/more-menu" + +export default function MorePage() { + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index f82bb8c..1ab6f7e 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -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 (
- -
-
+ {/* Bunn-fanerad (2026-08-01) er fixed/56px + safe-area -- pb-24 gir +
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). */} +

God dag, {me.first_name ?? me.display_name} @@ -527,10 +551,59 @@ export function Dashboard() {

+ +
) } +// 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 ( + + ) +} + // 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 }) { @@ -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 = ( <> -