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 ( -

-