diff --git a/.claude/settings.json b/.claude/settings.json index f0e63cd..0317147 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,10 @@ "Bash(grep -v \"ui/dropdown-menu\\\\|^.*DropdownMenuItem,$\\\\|import\")", "Bash(python3 test_friends.py)", "Bash(curl -s -o /dev/null -w \"people/search \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/people/search?q=test\")", - "Bash(curl -s -o /dev/null -w \"friends \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/friends\")" + "Bash(curl -s -o /dev/null -w \"friends \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/friends\")", + "Bash(rm -rf /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)", + "Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(19\\).zip\" .)" ] } } diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 04c937e..5a0f03d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -3038,21 +3038,25 @@ samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet: `viewer == owner_user_id OR viewer IN (SELECT user_id FROM round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`. -**Status: 🔨 FASE 1 (venner-kjernen) BACKEND BYGGET OG SCRATCH-VERIFISERT -2026-07-25** — migrasjon `025_friends.sql` + `app/routers/friends.py` -(`GET /people/search`, `POST /friends`, `POST /friends/{id}/accept`, -`DELETE /friends/{id}`, `GET /friends`, `PUT /friends/{friend_user_id}/ -categories`). 31 scratch-sjekker bestått, inkl. presist bevist tiered -rangering (venn→klubb→land→globalt) og at kategorisering faktisk er -PRIVAT (B ser aldri kategoriene A har satt B i). `test_isolation.sql` -fortsatt 12/12. **IKKE rullet ut mot ekte systemer ennå** — venter på -brukerens bekreftelse. **Frontend IKKE bygget** — V0-prompt skrevet i -FEATURE_BACKLOG.md, klar til å kjøres (ny rute foreslått som -`/my-friends`, ikke `/friends` — det er nå API-prefikset). Fase 2 -(rundevisibilitet) og fase 3 (ekte medspillere, inkl. den nylig avklarte -"skriv for hele flighten"-regelen) er fortsatt kun designet, ikke bygget. -Se "Venner og kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for -full detalj og V0-prompten. +**Status: 🔨 FASE 1 (venner-kjernen) BACKEND + FRONTEND BYGGET +2026-07-25.** Backend: migrasjon `025_friends.sql` + `app/routers/ +friends.py` (`GET /people/search`, `POST /friends`, `POST /friends/{id}/ +accept`, `DELETE /friends/{id}`, `GET /friends`, `PUT /friends/ +{friend_user_id}/categories`) — 31 scratch-sjekker bestått, inkl. +presist bevist tiered rangering (venn→klubb→land→globalt) og at +kategorisering faktisk er PRIVAT (B ser aldri kategoriene A har satt B +i). `test_isolation.sql` fortsatt 12/12. Rullet ut mot ekte `teecup_db`/ +`teecup_api` samme dag, bruker bekreftet eksplisitt. +Frontend: V0-zip (zip 19) integrert som `components/friends.tsx` + ny +rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo — +det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra +start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i +samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. +**IKKE rullet ut ennå (frontend-delen)** — venter på brukerens +bekreftelse. Fase 2 (rundevisibilitet) og fase 3 (ekte medspillere, inkl. +den avklarte "skriv for hele flighten"-regelen) er fortsatt kun +designet, ikke bygget. Se "Venner og kategorisert deling"-seksjonen i +FEATURE_BACKLOG.md for full detalj. --- diff --git a/CLAUDE.md b/CLAUDE.md index 235296c..60feab3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2677,6 +2677,36 @@ Ferdig og verifisert: FEATURE_BACKLOG.md i stedet, matcher etablert mønster/tidligere korrigering) — venter på at brukeren kjører den i v0.app. +- **ADR-036 fase 1 FRONTEND BYGGET, IKKE ENNÅ RULLET UT (2026-07-25):** + zip 19 mottatt og integrert — kun `components/friends.tsx` og + `app/friends/page.tsx` var reelt nye fra V0 (samme full-reeksport- + mønster som alltid, resten forventede reverts av allerede tilpassede + filer, hoppet over). **V0s egen rute (`/friends`) BEVISST IKKE brukt** + — flyttet til `/my-friends`, siden `/friends` nå er API-prefikset + (samme kollisjonsklasse som `/rounds` → `/my-rounds` tidligere, unngått + fra start denne gangen i stedet for oppdaget i produksjon). + Datalag skrevet fullstendig om fra V0s mock til ekte fetch mot + `/people/search`+`/friends`-endepunktene — TS-typene speiler Pydantic- + modellene i `app/routers/friends.py` felt-for-felt. Kategori-koder + (`spouse`, `golf_friends`, osv.) mappet mot V0s norske visningsnavn via + en delt `CATEGORY_OPTIONS`-liste i SAMME rekkefølge som backend sin + `Category`-type. Søkefeltet håndhever samme 2-tegns-minimum som + backend (viser en forklarende tekst i stedet for å bare returnere + tomt). "Send forespørsel"/"Godta"/"Avslå"/"Kanseller"/"Fjern venn" + trigger alle en full refetch av `GET /friends` etterpå (samme + refetch-etter-mutasjon-mønster som resten av appen) — kun kategori- + avkrysning er lokalt optimistisk (matcher PUT-endepunktets + erstatt-hele-settet-kontrakt). + **Dashbordets "Venner"-blokk koblet til ekte data i samme runde:** + root-komponenten henter nå `GET /friends`, viser ekte avatar-initialer + + antall venner + ventende forespørsler (samme visuelle design som + opprinnelig i zip 18, som ble bevisst forenklet til en inert tom- + tilstand forrige runde siden backend ikke fantes ennå) — begge + knappene ("Se venner"/"Søk etter venner") lenker nå til `/my-friends`. + Ekte typesjekket produksjonsbuild kompilerte rent, `/my-friends` listet + blant 22 ruter. **IKKE rullet ut ennå** — venter på brukerens + bekreftelse (ren frontend-endring, ingen migrasjon/backend-kode rørt). + Neste steg: 1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign (ADR-035) + venner/kategorisert deling (ADR-036), begge designet diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index c1fe2c5..957e7be 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1477,7 +1477,7 @@ gjennomgang av blokk-forslaget over først): --- -## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet +## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND+FRONTEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet Reist av brukeren samme runde som dashbord-forslaget over. Fullt design skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for @@ -1568,14 +1568,19 @@ etterpå fungerer. `test_isolation.sql` fortsatt 12/12. Ekte typesjekket produksjonsbuild kompilerte rent (ingen frontend-endring ennå utover `next.config.mjs` sine nye rewrites for `/people`/`/friends`). -**Frontend IKKE bygget** — venter bevisst på V0 (etablert mønster for ALL -frontend i prosjektet, jf. brukerens tidligere eksplisitte korrigering av -at rundeskjermene opprinnelig ble hånd-kodet i stedet). Ny frontend-rute -foreslått som `/my-friends` (IKKE `/friends` — det er nå API-prefikset, -samme kollisjonsfelle som `/rounds` vs. `/my-rounds`, unngått fra start -denne gangen). +**Frontend BYGGET 2026-07-25, samme dag** — zip 19 mottatt (V0-prompten +under kjørt av bruker), integrert som `components/friends.tsx` + ny rute +`app/my-friends/page.tsx` (IKKE `app/friends/page.tsx` slik V0 selv +foreslo — flyttet bevisst for å unngå kollisjon med API-prefikset +`/friends`, samme klasse feil som `/rounds` vs. `/my-rounds` tidligere, +denne gangen unngått fra start). Datalag skrevet om fra mock til ekte +fetch, kategori-koder mappet mot norske visningsnavn i samme rekkefølge +som backend. Dashbordets "Venner"-blokk (`dashboard.tsx`) koblet til +ekte `GET /friends`-data i samme runde — viser nå ekte antall +venner/ventende forespørsler, lenker til `/my-friends`. Ekte +typesjekket produksjonsbuild kompilerte rent. -**V0-prompt skrevet, klar til å kjøres:** +**V0-prompten som ble brukt (til referanse):** > Design en ny "Mine venner"-side for TeeCup (golf-app, Next.js + > Tailwind + shadcn/ui, mobil-først, eksisterende merkevarefarger). diff --git a/frontend/app/my-friends/page.tsx b/frontend/app/my-friends/page.tsx new file mode 100644 index 0000000..56facc8 --- /dev/null +++ b/frontend/app/my-friends/page.tsx @@ -0,0 +1,5 @@ +import { Friends } from "@/components/friends" + +export default function MyFriendsPage() { + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index f57537f..9e9c5ad 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { + ArrowRight, Building2, ChevronRight, Flag, @@ -98,6 +99,13 @@ type ApiRound = { type ApiHandicapPoint = { handicap_index: number; recorded_at: string } +// ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele +// GET /friends-responsen, som også bærer utgående forespørsler/kategorier). +type ApiFriendsSummary = { + friends: { first_name: string | null; last_name: string | null }[] + incoming_requests: unknown[] +} + function toTournament(t: ApiTournament): Tournament { return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined } } @@ -184,6 +192,7 @@ export function Dashboard() { const [rounds, setRounds] = useState(null) const [combinedTournaments, setCombinedTournaments] = useState([]) const [hcpHistory, setHcpHistory] = useState([]) + const [friendsSummary, setFriendsSummary] = useState(null) const [error, setError] = useState(null) const loadMe = useCallback(async () => { @@ -219,6 +228,10 @@ export function Dashboard() { .then((res) => (res.ok ? res.json() : [])) .then((data: ApiHandicapPoint[]) => setHcpHistory(data)) .catch(() => {}) + fetch("/friends", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) + .catch(() => {}) }, []) useEffect(() => { @@ -373,7 +386,7 @@ export function Dashboard() { - + @@ -796,18 +809,58 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l } // --- 6. Venner --------------------------------------------------------------- -// Vennekonseptet (ADR-036) er DESIGNET, ikke bygget ennå -- ingen backend å -// hente fra. Seksjonen vises som en ærlig tom-tilstand (matcher V0s design), -// men "Søk etter venner"-knappen er bevisst uten href/onClick (inert) til -// fase 1 av ADR-036 er bygget -- ingen /friends-side finnes å lenke til ennå. +// ADR-036 fase 1 (venner-kjernen) er live -- ekte data fra GET /friends. + +function friendInitials(first: string | null, last: string | null): string { + const f = (first ?? "").trim()[0] ?? "" + const l = (last ?? "").trim()[0] ?? "" + return (f + l).toUpperCase() || "?" +} + +function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) { + const friends = summary?.friends ?? [] + const pending = summary?.incoming_requests.length ?? 0 -function FriendsSection() { return (
- - - + {friends.length > 0 ? ( +
+
+
+ {friends.slice(0, 4).map((friend, i) => ( + 0 && "-ml-3", + )} + > + {friendInitials(friend.first_name, friend.last_name)} + + ))} +
+
+ + {friends.length} {friends.length === 1 ? "venn" : "venner"} + + {pending > 0 ? ( + + + ) : ( + Ingen nye forespørsler + )} +
+
+ +
+ ) : ( + + + + )}
) } diff --git a/frontend/components/friends.tsx b/frontend/components/friends.tsx new file mode 100644 index 0000000..caef2b5 --- /dev/null +++ b/frontend/components/friends.tsx @@ -0,0 +1,564 @@ +"use client" + +// Venner-kjernen (ADR-036, fase 1). Presentasjon fra V0 (zip 19), datalag +// skrevet om fra mock til ekte fetch mot /people/search + /friends. +// Ruten er bevisst /my-friends, IKKE /friends -- det er nå API-prefikset +// (samme kollisjonsfelle unngått som /rounds -> /my-rounds tidligere). + +import type React from "react" +import { useCallback, useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { + ArrowLeft, + Check, + ChevronDown, + Search, + Tag, + Trash2, + UserPlus, + Users, + X, +} from "lucide-react" +import { Input } from "@/components/ui/input" +import { Wordmark } from "@/components/wordmark" +import { cn } from "@/lib/utils" + +// --- Kategorier -------------------------------------------------------------- +// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig. + +const CATEGORY_OPTIONS = [ + { code: "spouse", label: "Make" }, + { code: "close_family", label: "Nær familie" }, + { code: "extended_family", label: "Storfamilie" }, + { code: "close_friends", label: "Nære venner" }, + { code: "golf_friends", label: "Golfvenner" }, + { code: "colleagues", label: "Kollegaer" }, + { code: "business", label: "Forretningsforbindelser" }, + { code: "classmates", label: "Studiekamerater" }, + { code: "acquaintances", label: "Perifere bekjente" }, + { code: "other", label: "Ymse" }, +] as const + +function categoryLabel(code: string): string { + return CATEGORY_OPTIONS.find((c) => c.code === code)?.label ?? code +} + +// --- API-typer --------------------------------------------------------------- + +type ApiPersonMatch = { id: string; first_name: string; last_name: string; avatar_url: string | null; home_club: string | null } +type ApiFriendEntry = { + friendship_id: string + user_id: string + first_name: string | null + last_name: string | null + avatar_url: string | null + home_club: string | null + categories: string[] +} +type ApiPendingRequest = { + friendship_id: string + user_id: string + first_name: string | null + last_name: string | null + avatar_url: string | null + created_at: string +} +type ApiFriendsOut = { friends: ApiFriendEntry[]; incoming_requests: ApiPendingRequest[]; outgoing_requests: ApiPendingRequest[] } + +function fullName(first: string | null, last: string | null): string { + const name = [first, last].filter(Boolean).join(" ").trim() + return name || "Ukjent" +} + +// --- Page ------------------------------------------------------------------ + +export function Friends() { + const router = useRouter() + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + const loadFriends = useCallback(async () => { + try { + const res = await fetch("/friends", { credentials: "include" }) + if (res.status === 401) { + router.replace("/") + return + } + if (!res.ok) throw new Error(`friends: ${res.status}`) + setData(await res.json()) + } catch { + setError("Klarte ikke å hente vennelisten din. Prøv igjen om litt.") + } + }, [router]) + + useEffect(() => { + void loadFriends() + }, [loadFriends]) + + const friendUserIds = new Set(data?.friends.map((f) => f.user_id) ?? []) + const outgoingUserIds = new Set(data?.outgoing_requests.map((r) => r.user_id) ?? []) + + return ( +
+
+
+ +
+
+ +
+
+

+ Mine venner +

+

+ Finn folk du spiller med, håndter forespørsler, og organiser vennene dine i egne + kategorier. +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ + + +
+
+
+ ) +} + +// --- Section header -------------------------------------------------------- + +function SectionHeading({ children }: { children: React.ReactNode }) { + return

{children}

+} + +// --- 1. Search --------------------------------------------------------------- + +function SearchSection({ + friendUserIds, + outgoingUserIds, + onRequestSent, +}: { + friendUserIds: Set + outgoingUserIds: Set + onRequestSent: () => void +}) { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + const [justSent, setJustSent] = useState>({}) + + useEffect(() => { + const trimmed = query.trim() + if (trimmed.length < 2) { + setResults([]) + return + } + let cancelled = false + setSearching(true) + const timer = setTimeout(async () => { + try { + const res = await fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" }) + if (res.ok && !cancelled) setResults(await res.json()) + } catch { + // Stille -- listen blir bare uendret. + } finally { + if (!cancelled) setSearching(false) + } + }, 250) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [query]) + + async function sendRequest(personId: string) { + setJustSent((prev) => ({ ...prev, [personId]: true })) + try { + const res = await fetch("/friends", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ addressee_user_id: personId }), + }) + if (res.ok) onRequestSent() + } catch { + setJustSent((prev) => ({ ...prev, [personId]: false })) + } + } + + return ( +
+ Finn venner + +
+
+ + {query.trim().length < 2 ? ( +

+ Skriv minst to tegn for å søke etter spillere. +

+ ) : searching ? ( +

Søker …

+ ) : results.length === 0 ? ( +

+ Ingen spillere matcher «{query.trim()}». +

+ ) : ( +
    + {results.map((person) => { + const isFriend = friendUserIds.has(person.id) + const isSent = outgoingUserIds.has(person.id) || justSent[person.id] + return ( +
  • + +
    + + {fullName(person.first_name, person.last_name)} + + + {person.home_club ?? "Ingen hjemmeklubb"} + +
    + {isFriend ? ( + + + ) : isSent ? ( + + + ) : ( + + )} +
  • + ) + })} +
+ )} +
+ ) +} + +// --- 2. Requests ------------------------------------------------------------- + +function RequestsSection({ + incoming, + outgoing, + onChanged, +}: { + incoming: ApiPendingRequest[] + outgoing: ApiPendingRequest[] + onChanged: () => void +}) { + async function respond(friendshipId: string, action: "accept" | "remove") { + if (action === "accept") { + await fetch(`/friends/${friendshipId}/accept`, { method: "POST", credentials: "include" }) + } else { + await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" }) + } + onChanged() + } + + const hasAny = incoming.length > 0 || outgoing.length > 0 + if (!hasAny) return null + + return ( +
+ Forespørsler + + {incoming.length > 0 && ( +
+

Mottatt

+
    + {incoming.map((person) => ( +
  • + +
    + {fullName(person.first_name, person.last_name)} +
    +
    + + +
    +
  • + ))} +
+
+ )} + + {outgoing.length > 0 && ( +
+

Sendt

+
    + {outgoing.map((person) => ( +
  • + +
    + {fullName(person.first_name, person.last_name)} + Venter pĂĄ svar +
    + +
  • + ))} +
+
+ )} +
+ ) +} + +// --- 3. Friends ---------------------------------------------------------------- + +function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onChanged: () => void }) { + return ( +
+ Venner + + {friends.length === 0 ? ( +
+ + +
+

Ingen venner ennĂĄ

+

+ Bruk søkefeltet øverst til å finne folk du spiller med, og send dem en forespørsel. +

+
+
+ ) : ( +
    + {friends.map((friend) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + +function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: () => void }) { + const [expanded, setExpanded] = useState(false) + const [confirming, setConfirming] = useState(false) + // Lokal, optimistisk kopi -- lagres til backend ved hver endring (PUT + // erstatter HELE settet, matcher backend-kontrakten), men holdes lokalt + // for umiddelbar UI-respons uten en full refetch per klikk. + const [categories, setCategories] = useState(friend.categories) + + async function toggleCategory(code: string) { + const next = categories.includes(code) ? categories.filter((c) => c !== code) : [...categories, code] + setCategories(next) + await fetch(`/friends/${friend.user_id}/categories`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ categories: next }), + }) + } + + async function removeFriend() { + await fetch(`/friends/${friend.friendship_id}`, { method: "DELETE", credentials: "include" }) + onChanged() + } + + const name = fullName(friend.first_name, friend.last_name) + + return ( +
+
+ +
+ {name} + {friend.home_club ?? "Ingen hjemmeklubb"} +
+
+ + {categories.length > 0 && ( +
    + {categories.map((code) => ( +
  • +
  • + ))} +
+ )} + +
+ + + {expanded && ( +
+
    + {CATEGORY_OPTIONS.map(({ code, label }) => { + const checked = categories.includes(code) + return ( +
  • + +
  • + ) + })} +
+

Kun du ser hvilke kategorier du har satt en venn i.

+
+ )} +
+ + {confirming ? ( +
+

Fjerne {name} som venn?

+
+ + +
+
+ ) : ( + + )} +
+ ) +} + +// --- Avatar ------------------------------------------------------------------ + +function initials(name: string) { + const parts = name.trim().split(/\s+/) + const first = parts[0]?.[0] ?? "" + const last = parts.length > 1 ? parts[parts.length - 1][0] : "" + return (first + last).toUpperCase() +} + +function Avatar({ name }: { name: string }) { + return ( + + ) +} diff --git a/tee-cup-login-screen (18).zip b/tee-cup-login-screen (18).zip deleted file mode 100644 index 319a338..0000000 Binary files a/tee-cup-login-screen (18).zip and /dev/null differ