From 27bff38afe58b35eb025d8c614c14c1bac6def58 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 1 Aug 2026 21:15:07 +0200 Subject: [PATCH] Kort: selve grunnfargen er et bevisst, ekte match. Hvordan overflatene BYGGES OPP (glass vs. flatt, boxed vs. flytende) er derimot forskjellig, og det er V0 sin egen tolkning, ikke noe hentet direkte fra dette bildet. --- .claude/settings.local.json | 10 +- app/routers/rounds.py | 100 ++++ frontend/app/page.tsx | 12 +- frontend/components/dashboard.tsx | 382 +++++++++---- frontend/components/install-prompt.tsx | 88 +-- frontend/components/login-form.tsx | 764 +++++++++++++++---------- frontend/tsconfig.tsbuildinfo | 2 +- tee-cup-login-screen (21).zip | Bin 179170 -> 0 bytes v0-zip21-live-leaderboard.png | Bin 75536 -> 0 bytes v0-zip21-team-roster.png | Bin 30797 -> 0 bytes 10 files changed, 927 insertions(+), 431 deletions(-) delete mode 100644 tee-cup-login-screen (21).zip delete mode 100644 v0-zip21-live-leaderboard.png delete mode 100644 v0-zip21-team-roster.png diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bc16562..7e7d444 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -382,7 +382,15 @@ "Bash(python3 test_round_starthole_time_nearby.py)", "Bash(python3 test_round_strokes_received.py)", "Bash(grep -n \"nearby-endepunkt\\\\|nærmest deg\\\\|Nærmest\\\\|/health\\\\`/\\\\`/dashboard\\\\`/\\\\`/my-rounds/new\\\\`\" /opt/teecup/ARCHITECTURE_DECISIONS.md)", - "mcp__chrome-devtools__get_network_request" + "mcp__chrome-devtools__get_network_request", + "Bash(curl -s -c /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/login-password -H 'Content-Type: application/json' -d '{\"email\":\"pwtest@example.com\",\"password\":\"testpassord123\"}')", + "Bash(python3 -c \"import json,sys;print\\(json.load\\(sys.stdin\\)\\)\")", + "Bash(curl -s -b /tmp/scratch_cookies2.txt -X POST http://localhost:3100/auth/2fa/setup/start -H 'Content-Type: application/json' -d '{\"method\":\"totp\"}')", + "Bash(python3 -c \"import json;d=json.load\\(open\\('/tmp/totp_setup.json'\\)\\);print\\(d.get\\('status'\\), d.get\\('secret'\\)\\)\")", + "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)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/app/routers/rounds.py b/app/routers/rounds.py index ad55387..5e023b1 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -1615,6 +1615,106 @@ async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) -> return [r["friend_id"] for r in matched] +# --------------------------------------------------------------------------- +# "Venner på banen" (dashbord-redesign 2026-08-01) -- venner som enten +# spiller en aktiv runde AKKURAT NÅ, eller fullførte en innen de siste 24 +# timene. Motsatt retning av `_friends_who_can_see_round` (der starter vi +# fra EIEREN og finner vennene deres) -- her starter vi fra VIEWEREN og +# finner vennenes runder, filtrert gjennom SAMME synlighetsregel som +# `_can_view_round` sin 'friends'-gren (kopiert inn i SQL-en under for å +# gjøre dette til ett enkelt sett-oppslag i stedet for N kall til +# `_can_view_round` per kandidatrunde -- reelt greit på denne skalaen, +# siden en brukers vennlisteantall er lite). +# --------------------------------------------------------------------------- + + +class FriendOnCourseEntry(BaseModel): + round_id: str + friend_user_id: str + friend_name: str + course_name: str + play_format: str + status: Literal["playing", "recently_finished"] + holes_played: int + holes_planned: int + score_to_par: int | None + started_at: str | None + completed_at: str | None + + +@router.get("/friends/on-course", response_model=list[FriendOnCourseEntry]) +async def list_friends_on_course(user: CurrentUser = Depends(get_current_user)) -> list[FriendOnCourseEntry]: + async with plain_connection() as conn: + rows = await conn.fetch( + """ + SELECT r.id::text AS round_id, r.owner_user_id::text AS owner_user_id, + COALESCE(au.first_name || ' ' || au.last_name, au.display_name) AS friend_name, + r.course_name_snapshot AS course_name, r.play_format, + r.holes_planned, r.started_at, r.completed_at, + COUNT(rh.*) FILTER (WHERE rh.played) AS holes_played, + COALESCE(SUM(rh.score) FILTER (WHERE rh.played), 0) AS total_score, + COALESCE(SUM(rh.par) FILTER (WHERE rh.played), 0) AS total_par + FROM round r + JOIN app_user au ON au.id = r.owner_user_id + JOIN friendship f ON f.status = 'accepted' + AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id) + OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1)) + JOIN round_participant rp ON rp.round_id = r.id AND rp.user_id = r.owner_user_id + LEFT JOIN round_hole rh ON rh.round_participant_id = rp.id + WHERE r.owner_user_id != $1 + AND ( + (r.started_at IS NOT NULL AND r.completed_at IS NULL) + OR (r.completed_at IS NOT NULL AND r.completed_at >= now() - interval '24 hours') + ) + AND ( + r.visibility_mode = 'public' + OR ( + r.visibility_mode = 'friends' + AND EXISTS( + SELECT 1 FROM friend_categorization fc + WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 + ) + AND NOT EXISTS( + SELECT 1 FROM friend_categorization fc + WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1 + AND fc.category NOT IN ( + SELECT category FROM round_visible_category WHERE round_id = r.id + ) + ) + ) + ) + GROUP BY r.id, r.owner_user_id, au.first_name, au.last_name, au.display_name, + r.course_name_snapshot, r.play_format, r.holes_planned, r.started_at, r.completed_at + ORDER BY COALESCE(r.started_at, r.completed_at) DESC + """, + user.user_id, + ) + out: list[FriendOnCourseEntry] = [] + for r in rows: + holes_played = r["holes_played"] + score_to_par = ( + r["total_score"] - r["total_par"] + if holes_played > 0 and r["play_format"] in ("stroke", "stableford") + else None + ) + out.append( + FriendOnCourseEntry( + round_id=r["round_id"], + friend_user_id=r["owner_user_id"], + friend_name=r["friend_name"], + course_name=r["course_name"], + play_format=r["play_format"], + status="playing" if r["completed_at"] is None else "recently_finished", + holes_played=holes_played, + holes_planned=r["holes_planned"], + score_to_par=score_to_par, + started_at=r["started_at"].isoformat() if r["started_at"] else None, + completed_at=r["completed_at"].isoformat() if r["completed_at"] else None, + ) + ) + return out + + # --------------------------------------------------------------------------- # Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder -- # `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9261393..02219fe 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,7 +1,6 @@ import { cookies } from "next/headers" import { redirect } from "next/navigation" import { LoginForm } from "@/components/login-form" -import { Wordmark } from "@/components/wordmark" // Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom // next.config.mjs sin rewrites() (samme mønster som generateMetadata i @@ -50,13 +49,10 @@ export default async function Page() { return (
-
- -

- Logg inn for å følge turneringen din live. -

-
- + {/* Redesignet kort (2026-08-01, V0/Forest Green) har sin egen + innebygde header (logo + tittel/undertekst per tilstand) -- + den forrige separate Wordmark+tagline-headeren over kortet er + derfor fjernet for å unngå dobbel "TeeCup"-overskrift. */}

diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 6970c05..f82bb8c 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -7,6 +7,21 @@ // turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget // "opprett organisasjon"-steg for det vanlige tilfellet) -- se // ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A. +// +// "Forest Green"-visuell redesign (2026-08-01, V0 zip 23, samme retning som +// login-form.tsx): kun JSX/farger endret nedenfor -- all ekte datahenting, +// alle ekte handlers og alle delte komponenter (RoundCard/TournamentCard/ +// InstallPrompt/NewTournamentInline/JoinByCodeInline) er UENDRET, kun +// re-stylet der de selv definerer sin egen visuelle ramme. +// +// "Venner på banen" (ny seksjon, plassert rett under hurtighandlingene per +// brukerens eksplisitte ønske fra en tidligere Stitch-designsamtale) er en +// HELT NY funksjon -- viser venner med en AKTIV runde nå, eller en runde +// fullført innen de siste 24 timene, hentet fra det nye +// GET /friends/on-course-endepunktet (app/routers/rounds.py). Synlighet +// respekterer nøyaktig samme regel som all annen rundedeling (ADR-036 +// fase 2, `_can_view_round`s 'friends'-gren) -- håndhevet server-side, ikke +// duplisert her. import type React from "react" import { useCallback, useEffect, useState } from "react" @@ -21,10 +36,9 @@ import { KeyRound, LogOut, MapPin, - Plus, + Trophy, TrendingDown, TrendingUp, - Trophy, UserCircle, UserPlus, X, @@ -38,13 +52,32 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Wordmark } from "@/components/wordmark" import { InstallPrompt } from "@/components/install-prompt" import { RoundCard, type Round } from "@/components/round-card" 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). +const C = { + bg: "#f7faf8", + card: "#ffffff", + glass: "rgba(255, 255, 255, 0.78)", + ink: "#012c11", + inkOn: "#ffffff", + accent: "#1a4325", + accentMuted: "#bfeec4", + successBg: "#91f78e", + successInk: "#00731e", + muted: "#424941", + border: "#c1c9bf", + tint: "#edf3ef", + warmBg: "#ffedd5", + warmInk: "#9a3412", +} as const + // --- API-typer --------------------------------------------------------------- type MyOrg = { organization_id: string; name: string; role: string } @@ -118,6 +151,21 @@ type ApiFriendsSummary = { incoming_requests: unknown[] } +// "Venner på banen" -- speiler FriendOnCourseEntry i app/routers/rounds.py. +type ApiFriendOnCourse = { + round_id: string + friend_user_id: string + friend_name: string + course_name: string + play_format: string + status: "playing" | "recently_finished" + holes_played: number + holes_planned: number + score_to_par: number | null + started_at: string | null + completed_at: string | null +} + 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 } } @@ -212,6 +260,17 @@ function formatDate(value: string) { return dateFormatter.format(parsed) } +// "for N timer/minutter siden" -- kun brukt av "Venner på banen" sin +// "nylig fullført"-status. +function timeAgoLabel(iso: string): string { + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return "" + const minutes = Math.max(1, Math.round((Date.now() - then) / 60000)) + if (minutes < 60) return `${minutes} min siden` + const hours = Math.round(minutes / 60) + return `${hours} ${hours === 1 ? "time" : "timer"} siden` +} + // --- Root component ---------------------------------------------------------- export function Dashboard() { @@ -222,6 +281,7 @@ export function Dashboard() { const [combinedTournaments, setCombinedTournaments] = useState([]) const [hcpHistory, setHcpHistory] = useState([]) const [friendsSummary, setFriendsSummary] = useState(null) + const [friendsOnCourse, setFriendsOnCourse] = useState([]) const [unreadNotifications, setUnreadNotifications] = useState(0) const [error, setError] = useState(null) @@ -262,6 +322,10 @@ export function Dashboard() { .then((res) => (res.ok ? res.json() : null)) .then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) .catch(() => {}) + fetch("/friends/on-course", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: ApiFriendOnCourse[]) => setFriendsOnCourse(data)) + .catch(() => {}) fetch("/notifications/unread-count", { credentials: "include" }) .then((res) => (res.ok ? res.json() : { count: 0 })) .then((data: { count: number }) => setUnreadNotifications(data.count)) @@ -349,8 +413,8 @@ export function Dashboard() { if (loadingMe) { return ( -

-