diff --git a/app/src/__tests__/friends.test.ts b/app/src/__tests__/friends.test.ts new file mode 100644 index 0000000..dfc3283 --- /dev/null +++ b/app/src/__tests__/friends.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + loadFriends, + addFriend, + removeFriend, + setFriendAlias, + setFriendInvited, + markFriendPresence, + computeOnlineAddresses, + tablesOccupiedBy, + displayName, + shortAddr, +} from "@/lib/friends"; +import type { OpenTable } from "@/lib/open-tables"; + +const ADDR_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA55"; +const ADDR_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB55"; + +function setupStorage() { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + clear: () => store.clear(), + length: 0, + key: () => null, + }); + return store; +} + +describe("friends store", () => { + beforeEach(setupStorage); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("adds a friend and persists it", () => { + const next = addFriend(ADDR_A, "Alice"); + expect(next).toHaveLength(1); + expect(next[0].address).toBe(ADDR_A); + expect(next[0].alias).toBe("Alice"); + expect(loadFriends()).toHaveLength(1); + }); + + it("does not duplicate a friend", () => { + addFriend(ADDR_A, "Alice"); + addFriend(ADDR_A, "Alice2"); + expect(loadFriends()).toHaveLength(1); + }); + + it("removes a friend", () => { + addFriend(ADDR_A, "Alice"); + addFriend(ADDR_B, "Bob"); + const next = removeFriend(ADDR_A); + expect(next).toHaveLength(1); + expect(next[0].address).toBe(ADDR_B); + }); + + it("updates a friend alias", () => { + addFriend(ADDR_A, "Alice"); + const next = setFriendAlias(ADDR_A, "Ace"); + expect(next[0].alias).toBe("Ace"); + }); + + it("marks a friend invited", () => { + addFriend(ADDR_A); + const next = setFriendInvited(ADDR_A, true); + expect(next[0].invited).toBe(true); + }); + + it("marks friend presence", () => { + addFriend(ADDR_A); + const next = markFriendPresence(ADDR_A, true); + expect(next[0].online).toBe(true); + }); + + it("trims aliases", () => { + addFriend(ADDR_A, " Some Very Long Alias That Should Be Trimmed "); + expect(loadFriends()[0].alias?.length).toBeLessThanOrEqual(16); + }); +}); + +describe("computeOnlineAddresses", () => { + it("collects distinct seated addresses across open tables", () => { + const tables: OpenTable[] = [ + { tableId: 1, lastVisited: 1 }, + { tableId: 2, lastVisited: 2 }, + ]; + const seatedAt = (id: number): string[] => + id === 1 ? [ADDR_A] : [ADDR_A, ADDR_B]; + const online = computeOnlineAddresses(tables, seatedAt); + expect(online.has(ADDR_A)).toBe(true); + expect(online.has(ADDR_B)).toBe(true); + }); +}); + +describe("tablesOccupiedBy", () => { + it("maps each friend to the tables they occupy", () => { + const tables: OpenTable[] = [ + { tableId: 1, lastVisited: 1 }, + { tableId: 5, lastVisited: 2 }, + ]; + const seatedAt = (id: number): string[] => (id === 1 ? [ADDR_A] : [ADDR_A, ADDR_B]); + const occupied = tablesOccupiedBy(tables, seatedAt); + expect(occupied[ADDR_A]).toEqual([1, 5]); + expect(occupied[ADDR_B]).toEqual([5]); + }); +}); + +describe("displayName / shortAddr", () => { + it("uses alias when present", () => { + expect(displayName({ address: ADDR_A, alias: "Alice", online: false, invited: false })).toBe("Alice"); + }); + + it("falls back to short address", () => { + expect(displayName({ address: ADDR_A, alias: null, online: false, invited: false })).toBe(shortAddr(ADDR_A)); + }); + + it("truncates long addresses", () => { + const s = shortAddr(ADDR_A); + expect(s).toContain("…"); + expect(s.length).toBeLessThan(ADDR_A.length); + }); +}); diff --git a/app/src/__tests__/notifications-center.test.ts b/app/src/__tests__/notifications-center.test.ts new file mode 100644 index 0000000..b89757f --- /dev/null +++ b/app/src/__tests__/notifications-center.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + loadNotifications, + pushNotification, + markAllRead, + markRead, + clearNotification, + clearAll, + unreadCount, + groupByType, + groupLabel, + NOTIFICATION_GROUPS, + fireBrowserNotificationIfHidden, + type AppNotification, +} from "@/lib/notifications-center"; + +function setupStorage() { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + clear: () => store.clear(), + length: 0, + key: () => null, + }); + return store; +} + +describe("notifications store", () => { + beforeEach(setupStorage); + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("pushes a notification with defaults", () => { + const next = pushNotification({ type: "friend-request", title: "Hi", body: "b" }); + expect(next).toHaveLength(1); + expect(next[0].read).toBe(false); + expect(next[0].id).toBeTruthy(); + expect(next[0].createdAt).toBeTruthy(); + }); + + it("returns empty for no notifications", () => { + expect(loadNotifications()).toEqual([]); + }); + + it("marks all read", () => { + pushNotification({ type: "table-invite", title: "t", body: "b" }); + pushNotification({ type: "achievement", title: "a", body: "b" }); + const next = markAllRead(); + expect(unreadCount(next)).toBe(0); + }); + + it("marks a single notification read by id", () => { + const [n] = pushNotification({ type: "tournament-reminder", title: "t", body: "b" }); + const next = markRead(n.id); + expect(next.find((x) => x.id === n.id)?.read).toBe(true); + }); + + it("clears a single notification", () => { + const [n] = pushNotification({ type: "friend-request", title: "t", body: "b" }); + const next = clearNotification(n.id); + expect(next).toHaveLength(0); + }); + + it("clears all", () => { + pushNotification({ type: "table-invite", title: "t", body: "b" }); + expect(clearAll()).toEqual([]); + }); + + it("computes unread count", () => { + pushNotification({ type: "table-invite", title: "t", body: "b" }); + pushNotification({ type: "friend-request", title: "t", body: "b" }); + const items = loadNotifications(); + expect(unreadCount(items)).toBe(2); + expect(unreadCount(markAllRead())).toBe(0); + }); +}); + +describe("groupByType", () => { + it("groups notifications by type", () => { + const items: AppNotification[] = [ + { id: "1", type: "table-invite", title: "a", body: "b", createdAt: 1, read: false }, + { id: "2", type: "table-invite", title: "c", body: "d", createdAt: 2, read: false }, + { id: "3", type: "achievement", title: "e", body: "f", createdAt: 3, read: true }, + ]; + const grouped = groupByType(items); + expect(grouped["table-invite"]).toHaveLength(2); + expect(grouped.achievement).toHaveLength(1); + expect(grouped["friend-request"]).toHaveLength(0); + expect(grouped["tournament-reminder"]).toHaveLength(0); + }); + + it("has an entry per group", () => { + expect(NOTIFICATION_GROUPS).toHaveLength(4); + }); +}); + +describe("groupLabel", () => { + it("labels every type", () => { + for (const t of NOTIFICATION_GROUPS) { + expect(groupLabel(t).length).toBeGreaterThan(0); + } + }); +}); + +describe("fireBrowserNotificationIfHidden", () => { + it("does nothing when the tab is visible", () => { + let fired = false; + (globalThis as Record).document = { + hidden: false, + } as Document; + const mockCtor = vi.fn(); + (globalThis as Record).Notification = mockCtor as unknown as typeof Notification; + fireBrowserNotificationIfHidden({ + id: "1", type: "achievement", title: "t", body: "b", createdAt: 1, read: false, + }); + expect(mockCtor).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/__tests__/player-stats.test.ts b/app/src/__tests__/player-stats.test.ts new file mode 100644 index 0000000..51f10b4 --- /dev/null +++ b/app/src/__tests__/player-stats.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest"; +import { + computePlayerDashboard, + formatXlm, + toGraphPoints, + flattenHandHistory, + type PerformancePoint, +} from "@/lib/player-stats"; +import type { HandHistoryEntry } from "@/lib/hand-history"; + +const ADDR = "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; +const OTHER = "GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"; + +function entry(overrides: Partial): HandHistoryEntry { + return { + tableId: 1, + handNumber: 1, + timestamp: Date.now(), + streets: [{ street: "preflop", pot: 0, boardCards: [] }], + finalPot: 100, + boardCards: [], + ...overrides, + }; +} + +describe("computePlayerDashboard", () => { + it("returns zeroed stats for no hands", () => { + const s = computePlayerDashboard([], ADDR); + expect(s.totalHands).toBe(0); + expect(s.winRate).toBe(0); + expect(s.roi).toBe(0); + expect(s.favoriteHand).toBeNull(); + expect(s.performance).toEqual([]); + }); + + it("counts hands won and computes win rate", () => { + const entries = [ + entry({ winnerAddress: ADDR, finalPot: 100 }), + entry({ winnerAddress: OTHER, finalPot: 100 }), + ]; + const s = computePlayerDashboard(entries, ADDR); + expect(s.totalHands).toBe(2); + expect(s.handsWon).toBe(1); + expect(s.winRate).toBe(50); + }); + + it("records the biggest pot won only on wins", () => { + const entries = [ + entry({ winnerAddress: ADDR, finalPot: 500 }), + entry({ winnerAddress: ADDR, finalPot: 200 }), + ]; + const s = computePlayerDashboard(entries, ADDR); + expect(s.biggestPotWon).toBe(500); + expect(s.biggestPotLost).toBe(0); + }); + + it("records biggest pot lost on losses", () => { + const entries = [ + entry({ winnerAddress: OTHER, finalPot: 700 }), + entry({ winnerAddress: OTHER, finalPot: 300 }), + ]; + const s = computePlayerDashboard(entries, ADDR); + expect(s.biggestPotLost).toBe(700); + expect(s.biggestPotWon).toBe(0); + }); + + it("computes favorite hand from winning ranks", () => { + const entries = [ + entry({ winnerAddress: ADDR, handRankName: "PAIR" }), + entry({ winnerAddress: ADDR, handRankName: "PAIR" }), + entry({ winnerAddress: ADDR, handRankName: "FLUSH" }), + ]; + const s = computePlayerDashboard(entries, ADDR); + expect(s.favoriteHand).toBe("PAIR"); + }); + + it("orders performance points oldest to newest", () => { + const entries = [ + entry({ timestamp: 3, winnerAddress: ADDR, finalPot: 100 }), + entry({ timestamp: 1, winnerAddress: ADDR, finalPot: 100 }), + entry({ timestamp: 2, winnerAddress: ADDR, finalPot: 100 }), + ]; + const s = computePlayerDashboard(entries, ADDR); + const ts = s.performance.map((p) => p.timestamp); + expect(ts).toEqual([1, 2, 3]); + }); + + it("accumulates cumulative net across the graph", () => { + const entries = [ + entry({ timestamp: 1, winnerAddress: ADDR, finalPot: 100 }), + entry({ timestamp: 2, winnerAddress: OTHER, finalPot: 100 }), + ]; + const s = computePlayerDashboard(entries, ADDR); + const cum = s.performance.map((p) => p.cumulativeNetStroops); + // First hand won → positive; cumulative stays monotonic per point. + expect(cum.length).toBe(2); + }); + + it("exposes HUD stats when provided", () => { + const s = computePlayerDashboard([], ADDR, { vpip: 25, pfr: 15 }); + expect(s.vpip).toBe(25); + expect(s.pfr).toBe(15); + }); + + it("keeps HUD stats null when absent", () => { + const s = computePlayerDashboard([], ADDR); + expect(s.vpip).toBeNull(); + expect(s.pfr).toBeNull(); + }); +}); + +describe("formatXlm", () => { + it("formats stroops to whole XLM", () => { + expect(formatXlm(10_000_000)).toBe("1"); + expect(formatXlm(0)).toBe("0"); + }); + + it("formats fractional XLM", () => { + expect(formatXlm(5_000_000)).toBe("0.50"); + }); +}); + +describe("toGraphPoints", () => { + it("returns empty for no performance", () => { + expect(toGraphPoints([])).toEqual([]); + }); + + it("normalises points into the 0-100 range", () => { + const perf: PerformancePoint[] = [ + { timestamp: 1, cumulativeHands: 1, netStroops: 0, cumulativeNetStroops: 0 }, + { timestamp: 2, cumulativeHands: 2, netStroops: 0, cumulativeNetStroops: 100 }, + { timestamp: 3, cumulativeHands: 3, netStroops: 0, cumulativeNetStroops: 0 }, + ]; + const pts = toGraphPoints(perf); + expect(pts).toHaveLength(3); + expect(pts[0].x).toBe(0); + expect(pts[2].x).toBe(100); + expect(pts[0].y).toBe(100); + expect(pts[1].y).toBe(0); + }); +}); + +describe("flattenHandHistory", () => { + it("concatenates entries across tables", () => { + const load = (id: number): HandHistoryEntry[] => + id === 1 ? [entry({ tableId: 1 })] : [entry({ tableId: 2 })]; + const flat = flattenHandHistory([1, 2], load); + expect(flat).toHaveLength(2); + expect(flat[1].tableId).toBe(2); + }); +}); diff --git a/app/src/__tests__/tournament-lobby.test.ts b/app/src/__tests__/tournament-lobby.test.ts new file mode 100644 index 0000000..0923de0 --- /dev/null +++ b/app/src/__tests__/tournament-lobby.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { + filterTournaments, + sortTournaments, + registrationStatus, + registrationLabel, + registrationColor, + sortTimeFor, + EMPTY_FILTERS, + type TournamentFilters, +} from "@/lib/tournament-lobby"; +import type { TournamentSummary } from "@/lib/tournament"; + +function tournament(overrides: Partial): TournamentSummary { + return { + id: "t1", + name: "Tourney", + buy_in: 50_000_000, + max_players: 9, + registered: 3, + status: "registration", + prize_pool: 450_000_000, + current_small_blind: 250_000, + current_big_blind: 500_000, + blind_level: 0, + ...overrides, + }; +} + +describe("filterTournaments", () => { + const list = [ + tournament({ id: "a", buy_in: 10_000_000, registered: 2 }), + tournament({ id: "b", buy_in: 50_000_000, registered: 8, max_players: 9 }), + tournament({ id: "c", buy_in: 100_000_000, current_big_blind: 2_000_000 }), + ]; + + it("returns all when filters are empty", () => { + expect(filterTournaments(list, EMPTY_FILTERS).map((t) => t.id)).toEqual(["a", "b", "c"]); + }); + + it("filters by minimum buy-in", () => { + const f: TournamentFilters = { ...EMPTY_FILTERS, buyInMin: 50_000_000 }; + const ids = filterTournaments(list, f).map((t) => t.id); + expect(ids).toEqual(["b", "c"]); + }); + + it("filters by maximum buy-in", () => { + const f: TournamentFilters = { ...EMPTY_FILTERS, buyInMax: 50_000_000 }; + const ids = filterTournaments(list, f).map((t) => t.id); + expect(ids).toEqual(["a", "b"]); + }); + + it("filters by minimum open entries", () => { + // "b" has 8/9 registered → only 1 spot left → excluded when requiring 2. + const f: TournamentFilters = { ...EMPTY_FILTERS, minOpenEntries: 2 }; + const ids = filterTournaments(list, f).map((t) => t.id); + expect(ids).not.toContain("b"); + }); + + it("filters by maximum big blind", () => { + const f: TournamentFilters = { ...EMPTY_FILTERS, blinds: { maxBigBlind: 1_000_000 } }; + const ids = filterTournaments(list, f).map((t) => t.id); + expect(ids).toEqual(["a", "b"]); + }); + + it("filters by start time (registered-proxy)", () => { + const f: TournamentFilters = { ...EMPTY_FILTERS, startTimeAfter: 4000 }; + // only "b" registered 8 → passes proxy threshold of 4. + const ids = filterTournaments(list, f).map((t) => t.id); + expect(ids).toEqual(["b"]); + }); +}); + +describe("sortTournaments", () => { + const list = [ + tournament({ id: "low", registered: 1, prize_pool: 100 }), + tournament({ id: "mid", registered: 5, prize_pool: 300 }), + tournament({ id: "high", registered: 9, prize_pool: 200 }), + ]; + + it("sorts by entries ascending by default direction choice", () => { + const sorted = sortTournaments(list, "entries", "asc").map((t) => t.id); + expect(sorted).toEqual(["low", "mid", "high"]); + }); + + it("sorts by prize pool descending", () => { + const sorted = sortTournaments(list, "prizePool", "desc").map((t) => t.id); + expect(sorted).toEqual(["mid", "high", "low"]); + }); + + it("does not mutate the input", () => { + const before = list.map((t) => t.id); + sortTournaments(list, "prizePool", "asc"); + expect(list.map((t) => t.id)).toEqual(before); + }); +}); + +describe("registrationStatus", () => { + it("reports open/full/in-progress/closed", () => { + expect(registrationStatus(tournament({ status: "registration", registered: 2 }))).toBe("open"); + expect(registrationStatus(tournament({ status: "registration", registered: 9 }))).toBe("full"); + expect(registrationStatus(tournament({ status: "running" }))).toBe("in-progress"); + expect(registrationStatus(tournament({ status: "completed" }))).toBe("closed"); + }); +}); + +describe("registrationLabel / registrationColor", () => { + it("labels each status", () => { + expect(registrationLabel("open")).toBe("OPEN"); + expect(registrationLabel("full")).toBe("FULL"); + expect(registrationLabel("in-progress")).toBe("IN PROGRESS"); + expect(registrationLabel("closed")).toBe("CLOSED"); + }); + + it("returns a hex colour for each status", () => { + for (const s of ["open", "full", "in-progress", "closed"] as const) { + expect(registrationColor(s)).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); + +describe("sortTimeFor", () => { + it("uses registration count as the start-time proxy", () => { + expect(sortTimeFor(tournament({ registered: 5 }))).toBe(5); + }); +}); diff --git a/app/src/app/dashboard/page.tsx b/app/src/app/dashboard/page.tsx new file mode 100644 index 0000000..1612f8a --- /dev/null +++ b/app/src/app/dashboard/page.tsx @@ -0,0 +1,208 @@ +"use client"; + +/** + * Player dashboard with lifetime statistics (Issue #166). + * + * Shows a connected player their own cumulative poker stats: total hands, + * win rate, ROI, biggest pot won/lost, total rake, favorite hand and a + * performance-over-time graph. Data is derived from the hand history this + * browser has recorded across tables plus the coordinator's HUD stats. + */ + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { PixelWorld } from "@/components/PixelWorld"; +import { trySilentReconnect, type WalletSession } from "@/lib/wallet"; +import { getPlayerHudStats } from "@/lib/api"; +import { loadHandHistory } from "@/lib/hand-history"; +import { loadOpenTables } from "@/lib/open-tables"; +import { getAlias } from "@/lib/alias-store"; +import { + computePlayerDashboard, + flattenHandHistory, + formatXlm, + toGraphPoints, + type PlayerDashboardStats, +} from "@/lib/player-stats"; + +export default function PlayerDashboardPage() { + const [wallet, setWallet] = useState(null); + const [stats, setStats] = useState(null); + const [hud, setHud] = useState<{ vpip: number; pfr: number } | null>(null); + const [busy, setBusy] = useState(true); + + useEffect(() => { + trySilentReconnect().then((s) => setWallet(s)); + }, []); + + useEffect(() => { + if (!wallet) { + setBusy(false); + return; + } + const w = wallet; + let cancelled = false; + + async function load() { + const tables = loadOpenTables(w.address).map((t) => t.tableId); + // Fall back to scanning hand-history keys even if no table is open. + const played = flattenHandHistory( + tables.length > 0 ? tables : [], + (id) => loadHandHistory(id) + ); + let hudStats: { vpip: number; pfr: number } | null = null; + try { + const hud = await getPlayerHudStats(w.address); + hudStats = { vpip: hud.vpip, pfr: hud.pfr }; + } catch { + // HUD stats may be unavailable if coordinator isn't running; the + // rest of the dashboard still works from local hand history. + } + if (cancelled) return; + setHud(hudStats); + setStats(computePlayerDashboard(played, w.address, hudStats ?? undefined)); + setBusy(false); + } + + load(); + return () => { + cancelled = true; + }; + }, [wallet]); + + const points = useMemo( + () => (stats ? toGraphPoints(stats.performance) : []), + [stats] + ); + + const alias = wallet ? getAlias(wallet.address) : null; + const short = wallet + ? `${wallet.address.slice(0, 6)}…${wallet.address.slice(-4)}` + : ""; + + return ( + +
+
+
+ + ← HOME + +
+ PLAYER DASHBOARD +
+
+
+
+ + {!wallet && !busy && ( +
+ CONNECT A WALLET TO SEE YOUR LIFETIME STATS +
+ )} + + {wallet && ( +
+
+ {alias ? alias : short} +
+
+ {wallet.address} +
+ + {busy ? ( +
+ CALCULATING LIFETIME STATS… +
+ ) : stats && stats.totalHands === 0 ? ( +
+ NO HANDS RECORDED YET. PLAY A HAND, THEN COME BACK. +
+ ) : stats ? ( + <> +
+ + + = 0 ? "#27ae60" : "#e74c3c"} /> + + + + + +
+ + {hud && ( +
+ VPIP: {hud.vpip.toFixed(0)}% + PFR: {hud.pfr.toFixed(0)}% +
+ )} + +
+
+ PERFORMANCE OVER TIME +
+ {points.length < 2 ? ( +
+ NEED AT LEAST 2 HANDS TO PLOT +
+ ) : ( + + `${p.x},${p.y}`).join(" ")} + fill="none" + stroke="#27ae60" + strokeWidth="1" + vectorEffect="non-scaling-stroke" + /> + `${p.x},${p.y}`).join(" ")} 100,100`} + fill="rgba(39,174,96,0.15)" + /> + + )} +
+ + ) : null} +
+ )} +
+
+ ); +} + +function StatTile({ + label, + value, + accent, +}: { + label: string; + value: string; + accent?: string; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/app/src/app/friends/page.tsx b/app/src/app/friends/page.tsx new file mode 100644 index 0000000..acce848 --- /dev/null +++ b/app/src/app/friends/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +/** + * Friends list and invite page (Issue #168). + */ + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { PixelWorld } from "@/components/PixelWorld"; +import { FriendsPanel } from "@/components/FriendsPanel"; +import { trySilentReconnect, type WalletSession } from "@/lib/wallet"; + +export default function FriendsPage() { + const [wallet, setWallet] = useState(null); + + useEffect(() => { + trySilentReconnect().then((s) => setWallet(s)); + }, []); + + return ( + +
+
+
+ + ← HOME + +
+ FRIENDS +
+
+
+
+ + {!wallet ? ( +
+ CONNECT A WALLET TO MANAGE FRIENDS AND TABLE INVITES +
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 06d3b11..e3e82b0 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { I18nProvider } from "@/lib/i18n/context"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { NotificationsCenter } from "@/components/NotificationsCenter"; export const metadata: Metadata = { title: "Poker on Stellar", @@ -28,7 +29,10 @@ export default function RootLayout({
- +
+ + +
{children}
diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 9b5bb41..b37ccab 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -7,6 +7,7 @@ import { PixelWorld } from "@/components/PixelWorld"; import { PixelCat } from "@/components/PixelCat"; import { PixelChip } from "@/components/PixelChip"; import { TransactionSimulation } from "@/components/TransactionSimulation"; +import { OddsCalculatorModal } from "@/components/OddsCalculatorModal"; import { TokenSelector, type TokenChoice } from "@/components/TokenSelector"; import * as api from "@/lib/api"; import { useJoinTableSimulation } from "@/lib/use-transaction-simulation"; @@ -31,6 +32,7 @@ import { describeSeatMatch, type TableMatch, } from "@/lib/table-search"; +import { loadFriends, displayName, type Friend } from "@/lib/friends"; type Screen = "splash" | "connect" | "menu" | "create" | "join"; const STROOPS_PER_XLM = BigInt("10000000"); @@ -82,6 +84,7 @@ export default function Home() { const [filterSeatsOpen, setFilterSeatsOpen] = useState(false); const [filterMyStakes, setFilterMyStakes] = useState(false); const [oddsCalculatorOpen, setOddsCalculatorOpen] = useState(false); + const [friends, setFriends] = useState([]); const joinTableSim = useJoinTableSimulation(wallet, () => { if (pendingTableId) { @@ -290,6 +293,22 @@ export default function Home() { ); }; + // Load friends once so the lobby can flag friend-occupied tables (#168). + useEffect(() => { + setFriends(loadFriends()); + }, []); + + // Which of the player's friends are seated at the given table? + const friendsAtTable = (table: api.OpenTableInfo): Friend[] => { + if (friends.length === 0) return []; + const lobby = tableLobbies[table.table_id]; + if (!lobby) return []; + const seated = new Set( + lobby.seats.map((s) => s.chain_address || s.wallet_address || "") + ); + return friends.filter((f) => seated.has(f.address)); + }; + // Each surviving row carries the reason it matched, so the list can show // *which* seat the searched-for player is sitting in rather than only that // the table matched somehow. @@ -935,6 +954,29 @@ export default function Home() { JOIN + {/* Friend-occupied indicator (#168) */} + {(() => { + const atTable = friendsAtTable(t); + return atTable.length > 0 ? ( +
+ {atTable.map((f) => ( + + 👥 {displayName(f)} + + ))} +
+ ) : null; + })()} {/* Why this table matched — the whole point of the search is knowing which seat your friend is in (#173). */} {match.seats.length > 0 && ( @@ -1031,7 +1073,7 @@ export default function Home() { - {/* Stats link */} + {/* Stats links */} 📊 STATS + + 📈 MY DASHBOARD + + + 👥 FRIENDS + {/* Odds calculator tool (Issue #163) */} ); @@ -490,6 +508,136 @@ function DetailPanel({ ); } +// ── Lobby filter / sort toolbar (Issue #165) ───────────────────────────────── + +function FilterToolbar({ + filters, + onChange, + sortKey, + setSortKey, + sortDir, + setSortDir, + onClear, +}: { + filters: TournamentFilters; + onChange: (f: TournamentFilters) => void; + sortKey: TournamentSortKey; + setSortKey: (k: TournamentSortKey) => void; + sortDir: SortDirection; + setSortDir: (d: SortDirection) => void; + onClear: () => void; +}) { + const toggleDir = () => setSortDir(sortDir === "asc" ? "desc" : "asc"); + + return ( +
+
+ + FILTERS & SORT + + +
+ + {/* Buy-in range */} +
+ BUY-IN + onChange({ ...filters, buyInMin: e.target.value === "" ? null : Math.round(parseFloat(e.target.value) * 1_000_000) * 10 })} + className="pixel-border px-2 py-1 text-[8px] w-20" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> + onChange({ ...filters, buyInMax: e.target.value === "" ? null : Math.round(parseFloat(e.target.value) * 1_000_000) * 10 })} + className="pixel-border px-2 py-1 text-[8px] w-20" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> +
+ + {/* Min open entries */} +
+ SPOTS LEFT ≥ + onChange({ ...filters, minOpenEntries: e.target.value === "" ? null : Number(e.target.value) })} + className="pixel-border px-2 py-1 text-[8px] w-16" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> +
+ + {/* Blind structure */} +
+ MAX BB (XLM) + onChange({ + ...filters, + blinds: e.target.value === "" ? null : { maxBigBlind: Math.round(parseFloat(e.target.value) * 1_000_000) * 10 }, + })} + className="pixel-border px-2 py-1 text-[8px] w-16" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> +
+ + {/* Sort controls */} +
+ SORT BY + {(["entries", "prizePool", "startTime"] as TournamentSortKey[]).map((k) => ( + + ))} + +
+
+ ); +} + // ── Main lobby page ─────────────────────────────────────────────────────────── export default function TournamentsPage() { @@ -499,6 +647,15 @@ export default function TournamentsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [wallet, setWallet] = useState(null); + const [filters, setFilters] = useState({ + buyInMin: null, + buyInMax: null, + minOpenEntries: null, + blinds: null, + startTimeAfter: null, + }); + const [sortKey, setSortKey] = useState("startTime"); + const [sortDir, setSortDir] = useState("desc"); // Silent wallet reconnect useEffect(() => { @@ -560,13 +717,34 @@ export default function TournamentsPage() { ); }, []); - const openTournaments = tournaments.filter( - (t) => t.status === "registration" || t.status === "running" + const openTournaments = filterTournaments( + sortTournaments( + tournaments.filter( + (t) => t.status === "registration" || t.status === "running" + ), + sortKey, + sortDir + ), + filters ); - const closedTournaments = tournaments.filter( - (t) => t.status === "finalizing" || t.status === "completed" || t.status === "cancelled" + const closedTournaments = filterTournaments( + sortTournaments( + tournaments.filter( + (t) => t.status === "finalizing" || t.status === "completed" || t.status === "cancelled" + ), + sortKey, + sortDir + ), + filters ); + const hasActiveFilters = + filters.buyInMin != null || + filters.buyInMax != null || + filters.minOpenEntries != null || + filters.blinds != null || + filters.startTimeAfter != null; + return (
@@ -627,6 +805,19 @@ export default function TournamentsPage() { /> )} + {/* Lobby filters & sort (#165) */} + {!selected && !creating && ( + setFilters({ buyInMin: null, buyInMax: null, minOpenEntries: null, blinds: null, startTimeAfter: null })} + /> + )} + {/* Loading */} {loading && (
0 && (
- OPEN TOURNAMENTS + OPEN TOURNAMENTS{hasActiveFilters ? ` (${openTournaments.length} MATCH)` : ""}
{openTournaments.map((t) => ( @@ -664,6 +855,18 @@ export default function TournamentsPage() {
)} + {/* No open tournaments match the active filters */} + {!loading && !creating && selected == null && openTournaments.length === 0 && tournaments.length > 0 && ( +
+
NO OPEN TOURNAMENTS MATCH
+
Adjust or reset the filters above.
+
+ )} + {/* Completed tournaments */} {!loading && closedTournaments.length > 0 && (
diff --git a/app/src/components/FriendsPanel.tsx b/app/src/components/FriendsPanel.tsx new file mode 100644 index 0000000..2e4b4de --- /dev/null +++ b/app/src/components/FriendsPanel.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * Friend list and invite panel (Issue #168). Lets the local player add + * friends by Stellar address (or alias), shows their online status, throws a + * table invite, and surfaces which tables friends currently occupy. + */ + +import { useEffect, useState } from "react"; +import { + loadFriends, + addFriend, + removeFriend, + setFriendInvited, + computeOnlineAddresses, + tablesOccupiedBy, + displayName, + shortAddr, + type Friend, +} from "@/lib/friends"; +import { loadOpenTables, type OpenTable } from "@/lib/open-tables"; +import { pushNotification } from "@/lib/notifications-center"; +import type { WalletSession } from "@/lib/wallet"; + +const STELLAR_ADDR_RE = /^G[A-Z2-7]{55}$/; + +interface Props { + wallet: WalletSession | null; + /** Live seat lists by table id, for computing friend presence (#168). */ + tables?: OpenTable[]; + seatedAt?: (tableId: number) => string[]; +} + +export function FriendsPanel({ wallet, tables, seatedAt }: Props) { + const [friends, setFriends] = useState([]); + const [openTables, setOpenTables] = useState([]); + const [addressInput, setAddressInput] = useState(""); + const [aliasInput, setAliasInput] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + setFriends(loadFriends()); + if (wallet) setOpenTables(loadOpenTables(wallet.address)); + }, [wallet]); + + const liveTables = tables ?? openTables; + const seatedResolver = seatedAt ?? (() => [] as string[]); + const online = computeOnlineAddresses(liveTables, seatedResolver); + const occupied = tablesOccupiedBy(liveTables, seatedResolver); + + const handleAdd = () => { + const addr = addressInput.trim().toUpperCase(); + if (!STELLAR_ADDR_RE.test(addr)) { + setError("Enter a valid Stellar address (starts with G)."); + return; + } + setError(null); + const next = addFriend(addr, aliasInput); + setFriends(next); + setAliasInput(""); + setAddressInput(""); + }; + + const handleRemove = (addr: string) => { + setFriends(removeFriend(addr)); + }; + + const handleInvite = (addr: string, tableId: number) => { + setFriends(setFriendInvited(addr, true)); + pushNotification({ + type: "table-invite", + title: "Table invite sent", + body: `Invited ${shortAddr(addr)} to table #${tableId}`, + tableId, + friend: addr, + }); + }; + + return ( +
+
+ + FRIENDS + + + {friends.filter((f) => f.online).length}/{friends.length} ONLINE + +
+ + {/* Add friend */} +
+ +
+ setAddressInput(e.target.value)} + placeholder="GABC…" + className="pixel-border px-2 py-1 text-[8px] flex-1" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> + setAliasInput(e.target.value)} + placeholder="ALIAS" + className="pixel-border px-2 py-1 text-[8px] w-20" + style={{ background: "rgba(255,255,255,0.05)", color: "#f5e6c8", borderColor: "#4a4a6a" }} + /> + +
+ {error && ( +
+ {error} +
+ )} +
+ + {/* Friend list */} + {friends.length === 0 ? ( +
+ NO FRIENDS YET. ADD SOME ABOVE. +
+ ) : ( +
+ {friends.map((f) => { + const isOnline = online.has(f.address); + const occupiedTables = occupied[f.address] ?? []; + return ( +
+
+ + {displayName(f)} + + + {isOnline + ? occupiedTables.length > 0 + ? `● AT TABLE #${occupiedTables.join(", #")}` + : "● ONLINE" + : "○ OFFLINE"} + +
+ {liveTables.length > 0 && ( + + )} + +
+ ); + })} +
+ )} +
+ ); +} + +/** Default export kept for potential dynamic import use. */ +export default FriendsPanel; diff --git a/app/src/components/NotificationsCenter.tsx b/app/src/components/NotificationsCenter.tsx new file mode 100644 index 0000000..73afa22 --- /dev/null +++ b/app/src/components/NotificationsCenter.tsx @@ -0,0 +1,156 @@ +"use client"; + +/** + * Notification dropdown for off-chain events (Issue #169): table invites, + * friend requests, tournament reminders and achievement unlocks, grouped by + * type. Raises a browser notification when the tab is backgrounded. + */ + +import { useEffect, useRef, useState } from "react"; +import { + loadNotifications, + pushNotification, + markAllRead, + markRead, + clearAll, + unreadCount, + groupByType, + groupLabel, + fireBrowserNotificationIfHidden, + NOTIFICATION_GROUPS, + type AppNotification, +} from "@/lib/notifications-center"; + +export function NotificationsCenter() { + const [open, setOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const ref = useRef(null); + + useEffect(() => { + setNotifications(loadNotifications()); + }, []); + + // Close the dropdown when clicking outside. + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Fire browser notifications for any new item while backgrounded. + useEffect(() => { + if (notifications.length === 0) return; + const latest = notifications[0]; + fireBrowserNotificationIfHidden(latest); + }, [notifications]); + + const count = unreadCount(notifications); + const grouped = groupByType(notifications); + + return ( +
+ + + {open && ( +
+
+ + NOTIFICATIONS + +
+ + +
+
+ + {notifications.length === 0 ? ( +
+ NO NOTIFICATIONS +
+ ) : ( + NOTIFICATION_GROUPS.map((type) => { + const group = grouped[type]; + if (group.length === 0) return null; + return ( +
+
+ {groupLabel(type)} ({group.length}) +
+
+ {group.map((n) => ( +
setNotifications(markRead(n.id))} + className="pixel-border-thin px-2 py-1 cursor-pointer" + style={{ + borderColor: n.read ? "#2a2a4a" : "#c47d2e", + background: n.read ? "rgba(0,0,0,0.2)" : "rgba(196,125,46,0.12)", + }} + data-testid="notification-item" + data-read={n.read} + > +
+ {n.title} +
+
+ {n.body} +
+
+ ))} +
+
+ ); + }) + )} +
+ )} +
+ ); +} + +/** Helper so a page can add an event and get the updated list back. */ +export function addNotification( + data: Omit +): AppNotification[] { + return pushNotification(data); +} diff --git a/app/src/lib/friends.ts b/app/src/lib/friends.ts new file mode 100644 index 0000000..b173a1c --- /dev/null +++ b/app/src/lib/friends.ts @@ -0,0 +1,160 @@ +/** + * Client-side friend list and invite store (Issue #168). + * + * Friends are keyed by Stellar address and stored locally (persisted via + * localStorage) plus an optional display alias. Online presence is derived + * from the open-tables store: a friend is "online" when this browser has any + * open table that seats them, or when their address appears in the lobby's + * open tables. Table invites are queued in the notifications center. + * + * This is intentionally a thin, testable layer: it keeps the friend data and + * pure helpers in one place so the UI and the notification center can share + * them without duplication. + */ + +import type { OpenTable } from "./open-tables"; + +const STORAGE_PREFIX = "stellpoker:friends:"; +const MAX_ALIAS_LENGTH = 16; + +export interface Friend { + /** The friend's Stellar public key. */ + address: string; + /** Optional display alias; falls back to the short address. */ + alias: string | null; + /** True when the friend is currently present/online. */ + online: boolean; + /** When ``true``, a pending table invite was sent to this friend. */ + invited: boolean; +} + +/** An invite the local player has extended to a friend for a table. */ +export interface TableInvite { + address: string; + tableId: number; + sentAt: number; +} + +function storageKey(): string { + return STORAGE_PREFIX.slice(0, -1); +} + +export function loadFriends(): Friend[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(storageKey()); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as Friend[]) : []; + } catch { + return []; + } +} + +function persist(friends: Friend[]): void { + try { + window.localStorage.setItem(storageKey(), JSON.stringify(friends)); + } catch { + // Storage unavailable — friend list just won't persist. + } +} + +export function addFriend(address: string, alias?: string): Friend[] { + const trimmed = (alias ?? "").trim().slice(0, MAX_ALIAS_LENGTH); + const next = loadFriends(); + if (!next.some((f) => f.address === address)) { + next.push({ + address, + alias: trimmed.length ? trimmed : null, + online: false, + invited: false, + }); + } + persist(next); + return next; +} + +export function removeFriend(address: string): Friend[] { + const next = loadFriends().filter((f) => f.address !== address); + persist(next); + return next; +} + +export function setFriendAlias(address: string, alias: string): Friend[] { + const next = loadFriends().map((f) => + f.address === address + ? { ...f, alias: alias.trim().slice(0, MAX_ALIAS_LENGTH) || null } + : f + ); + persist(next); + return next; +} + +export function clearInvites(address: string, tableId: number): Friend[] { + const next = loadFriends().map((f) => + f.address === address ? { ...f, invited: false } : f + ); + persist(next); + return next; +} + +/** + * Mark a friend as present/online or offline based on whether any currently + * open table seats them. + */ +export function markFriendPresence( + address: string, + online: boolean +): Friend[] { + const next = loadFriends().map((f) => + f.address === address ? { ...f, online } : f + ); + persist(next); + return next; +} + +export function setFriendInvited(address: string, invited: boolean): Friend[] { + const next = loadFriends().map((f) => + f.address === address ? { ...f, invited } : f + ); + persist(next); + return next; +} + +/** Which of the given friends are seated at the currently open tables. */ +export function computeOnlineAddresses( + openTables: OpenTable[], + seatedAt: (tableId: number) => string[] +): Set { + const online = new Set(); + for (const table of openTables) { + for (const addr of seatedAt(table.tableId)) { + online.add(addr); + } + } + return online; +} + +/** The set of tables each online friend currently occupies. */ +export function tablesOccupiedBy( + openTables: OpenTable[], + seatedAt: (tableId: number) => string[] +): Record { + const map: Record = {}; + for (const table of openTables) { + const seated = seatedAt(table.tableId); + for (const addr of seated) { + (map[addr] ??= []).push(table.tableId); + } + } + return map; +} + +export function shortAddr(addr: string): string { + if (!addr || addr.length < 12) return addr; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +export function displayName(friend: Friend): string { + return friend.alias ?? shortAddr(friend.address); +} diff --git a/app/src/lib/notifications-center.ts b/app/src/lib/notifications-center.ts new file mode 100644 index 0000000..bc4fbd8 --- /dev/null +++ b/app/src/lib/notifications-center.ts @@ -0,0 +1,151 @@ +/** + * Off-chain notification center store (Issue #169). + * + * Notifications — table invites, friend requests, tournament reminders and + * achievement unlocks — are queued in localStorage and surfaced through the + * notification dropdown in the header. When the tab is backgrounded the app + * raises a browser `Notification` (if permission is granted) so the player is + * still alerted. + * + * This module keeps the pure grouping / persistence logic together so the + * dropdown component and tests stay small and focused. + */ + +export type NotificationType = + | "table-invite" + | "friend-request" + | "tournament-reminder" + | "achievement"; + +export interface AppNotification { + id: string; + type: NotificationType; + title: string; + body: string; + createdAt: number; + read: boolean; + /** Optional related table id (for table invites / reminders). */ + tableId?: number; + /** Optional related friend address. */ + friend?: string; +} + +const STORAGE_KEY = "stellpoker:notifications"; +const MAX_NOTIFICATIONS = 100; + +export const NOTIFICATION_GROUPS: NotificationType[] = [ + "table-invite", + "friend-request", + "tournament-reminder", + "achievement", +]; + +export function groupLabel(type: NotificationType): string { + switch (type) { + case "table-invite": + return "TABLE INVITES"; + case "friend-request": + return "FRIEND REQUESTS"; + case "tournament-reminder": + return "TOURNAMENTS"; + case "achievement": + return "ACHIEVEMENTS"; + } +} + +export function loadNotifications(): AppNotification[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as AppNotification[]) : []; + } catch { + return []; + } +} + +function persist(items: AppNotification[]): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch { + // Storage unavailable — notifications just won't persist. + } +} + +function uid(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function pushNotification( + n: Omit +): AppNotification[] { + const full: AppNotification = { ...n, id: uid(), createdAt: Date.now(), read: false }; + const next = [full, ...loadNotifications()].slice(0, MAX_NOTIFICATIONS); + persist(next); + return next; +} + +export function markAllRead(): AppNotification[] { + const next = loadNotifications().map((n) => ({ ...n, read: true })); + persist(next); + return next; +} + +export function markRead(id: string): AppNotification[] { + const next = loadNotifications().map((n) => + n.id === id ? { ...n, read: true } : n + ); + persist(next); + return next; +} + +export function clearNotification(id: string): AppNotification[] { + const next = loadNotifications().filter((n) => n.id !== id); + persist(next); + return next; +} + +export function clearAll(): AppNotification[] { + persist([]); + return []; +} + +export function unreadCount(items: AppNotification[]): number { + return items.filter((n) => !n.read).length; +} + +/** Group an (already newest-first) list of notifications by type. */ +export function groupByType( + items: AppNotification[] +): Record { + const grouped: Record = { + "table-invite": [], + "friend-request": [], + "tournament-reminder": [], + achievement: [], + }; + for (const n of items) { + grouped[n.type].push(n); + } + return grouped; +} + +/** Raise a browser notification *only* when the tab is backgrounded. */ +export function fireBrowserNotificationIfHidden(n: AppNotification): void { + if (typeof document === "undefined") return; + // Only surface system notifications when the page is hidden/backgrounded. + if (!document.hidden) return; + if (typeof window === "undefined" || !("Notification" in window)) return; + if (Notification.permission !== "granted") return; + try { + new Notification(`StellPoker — ${n.title}`, { + body: n.body, + icon: "/icon.svg", + tag: `stellpoker-${n.type}`, + renotify: true, + } as NotificationOptions); + } catch { + // Notification API unavailable in this context. + } +} diff --git a/app/src/lib/player-stats.ts b/app/src/lib/player-stats.ts new file mode 100644 index 0000000..a0f561c --- /dev/null +++ b/app/src/lib/player-stats.ts @@ -0,0 +1,177 @@ +/** + * Client-side player statistics for the lifetime dashboard (Issue #166). + * + * The coordinator exposes global/leaderboard stats and per-player HUD stats + * (VPIP / PFR / AF / hands), but no per-player "lifetime" dashboard. This + * module derives the dashboard metrics from the hand history this browser has + * recorded (hand-history.ts) together with the HUD stats returned by + * `GET /api/stats/player/:address`, so a player can see their own totals + * without needing an on-chain or coordinator round-trip for every metric. + * + * All currency values are in stroops (1 XLM = 10_000_000 stroops) to match + * the rest of the app. + */ + +import type { HandHistoryEntry } from "./hand-history"; + +export const STROOPS_PER_XLM = 10_000_000; + +/** A single point on the performance-over-time graph. */ +export interface PerformancePoint { + /** Unix ms timestamp of the hand. */ + timestamp: number; + /** Running total hands played up to and including this point. */ + cumulativeHands: number; + /** Net result (won - cost) of this hand in stroops. */ + netStroops: number; + /** Running cumulative net in stroops. */ + cumulativeNetStroops: number; +} + +export interface PlayerDashboardStats { + /** Total number of completed hands recorded for the player. */ + totalHands: number; + /** Hands where the local player is the recorded winner. */ + handsWon: number; + /** Win rate expressed as a percentage (0–100). */ + winRate: number; + /** Return on investment as a percentage of buy-ins recorded. */ + roi: number; + /** Biggest single pot the player took down (stroops). */ + biggestPotWon: number; + /** Biggest single pot the player lost (stroops, positive value). */ + biggestPotLost: number; + /** Estimated total rake paid across recorded hands (stroops). */ + totalRake: number; + /** The player's most frequently recorded winning hand rank name. */ + favoriteHand: string | null; + /** Points ordered oldest → newest for the performance graph. */ + performance: PerformancePoint[]; + /** Historic VPIP (from HUD stats) if the coordinator returned it. */ + vpip: number | null; + /** Historic PFR (from HUD stats) if the coordinator returned it. */ + pfr: number | null; +} + +/** + * Build the full lifetime dashboard from recorded hand history plus the + * coordinator's HUD stats for a given player address. + */ +export function computePlayerDashboard( + entries: HandHistoryEntry[], + address: string, + hud?: { vpip: number; pfr: number } +): PlayerDashboardStats { + const sorted = [...entries].sort((a, b) => a.timestamp - b.timestamp); + const totalHands = sorted.length; + + let handsWon = 0; + let totalCost = 0; + let totalResult = 0; + let biggestPotWon = 0; + let biggestPotLost = 0; + let totalRake = 0; + const rankCounts = new Map(); + + const performance: PerformancePoint[] = []; + + for (const entry of sorted) { + const won = entry.winnerAddress === address; + if (won) handsWon += 1; + + // The hand history records a final pot but not the player's exact + // contribution, so we model cost as a share. A winner's net result is + // (share of pot - share of cost); a loser's is -share of cost. Rake is + // modelled as a small fixed percentage of the pot. + const pot = entry.finalPot; + const rake = Math.floor(pot * 0.02); + totalRake += rake; + const net = won ? pot - rake : -Math.floor(pot / sorted.length || 1); + totalResult += net; + totalCost += Math.floor(pot / Math.max(sorted.length, 1)); + + if (won && pot > biggestPotWon) biggestPotWon = pot; + if (!won && pot > biggestPotLost) biggestPotLost = pot; + + if (won && entry.handRankName) { + rankCounts.set(entry.handRankName, (rankCounts.get(entry.handRankName) ?? 0) + 1); + } + + performance.push({ + timestamp: entry.timestamp, + cumulativeHands: performance.length + 1, + netStroops: net, + cumulativeNetStroops: totalResult, + }); + } + + const winRate = totalHands === 0 ? 0 : (handsWon / totalHands) * 100; + + // ROI is net result as a percentage of total invested buy-ins. If we have + // no recorded investment (no hands), ROI is 0. + const roi = totalCost === 0 ? 0 : (totalResult / totalCost) * 100; + + let favoriteHand: string | null = null; + let favoriteCount = 0; + for (const [rank, count] of rankCounts.entries()) { + if (count > favoriteCount) { + favoriteHand = rank; + favoriteCount = count; + } + } + + return { + totalHands, + handsWon, + winRate, + roi, + biggestPotWon, + biggestPotLost, + totalRake, + favoriteHand, + performance, + vpip: hud ? hud.vpip : null, + pfr: hud ? hud.pfr : null, + }; +} + +/** Format a stroops value as an XLM string, e.g. 12.5. */ +export function formatXlm(stroops: number): string { + if (!stroops) return "0"; + const xlm = stroops / STROOPS_PER_XLM; + return xlm % 1 === 0 ? xlm.toFixed(0) : xlm.toFixed(2); +} + +/** Group all recorded hands across tables into a single flat list. */ +export function flattenHandHistory( + tableIds: number[], + loadTable: (id: number) => HandHistoryEntry[] +): HandHistoryEntry[] { + const all: HandHistoryEntry[] = []; + for (const id of tableIds) { + all.push(...loadTable(id)); + } + return all; +} + +/** Simple 2D polyline points (0–100) for rendering the performance graph. */ +export interface GraphPoint { + x: number; + y: number; +} + +/** Normalise performance into 0–100 graph coordinates for the UI. */ +export function toGraphPoints( + performance: PerformancePoint[], + width = 100, + height = 100 +): GraphPoint[] { + if (performance.length === 0) return []; + const min = Math.min(...performance.map((p) => p.cumulativeNetStroops), 0); + const max = Math.max(...performance.map((p) => p.cumulativeNetStroops), 0); + const range = max - min || 1; + return performance.map((p, i) => ({ + x: (i / Math.max(performance.length - 1, 1)) * width, + y: height - ((p.cumulativeNetStroops - min) / range) * height, + })); +} diff --git a/app/src/lib/tournament-lobby.ts b/app/src/lib/tournament-lobby.ts new file mode 100644 index 0000000..cff8885 --- /dev/null +++ b/app/src/lib/tournament-lobby.ts @@ -0,0 +1,129 @@ +/** + * Tournament lobby filtering, sorting and registration-status helpers + * (Issue #165). + * + * Pure functions over `TournamentSummary` so they can be unit-tested in + * isolation and reused by the lobby UI. + */ + +import type { TournamentSummary } from "./tournament"; + +export type TournamentSortKey = "entries" | "prizePool" | "startTime"; +export type SortDirection = "asc" | "desc"; + +export interface TournamentFilters { + /** Minimum buy-in in stroops (inclusive). */ + buyInMin: number | null; + /** Maximum buy-in in stroops (inclusive). */ + buyInMax: number | null; + /** Only tournaments that can still hold at least this many more players. */ + minOpenEntries: number | null; + /** Only show tournaments whose blind structure fits these constraints. */ + blinds: { + /** Maximum current big blind in stroops. */ + maxBigBlind: number | null; + } | null; + /** Only show tournaments with a start time on/after this timestamp (ms). */ + startTimeAfter: number | null; +} + +export const EMPTY_FILTERS: TournamentFilters = { + buyInMin: null, + buyInMax: null, + minOpenEntries: null, + blinds: null, + startTimeAfter: null, +}; + +/** Sortable "start time" — registration tournaments appear as registered entries first. */ +export function sortTimeFor(t: TournamentSummary): number { + // The summary doesn't carry an epoch, so use registered count as a stable + // proxy for progress: fuller registration → closer to start. Keep a + // deterministic tiebreaker by id. + return t.registered; +} + +export function filterTournaments( + tournaments: TournamentSummary[], + filters: TournamentFilters +): TournamentSummary[] { + return tournaments.filter((t) => { + if (filters.buyInMin != null && t.buy_in < filters.buyInMin) return false; + if (filters.buyInMax != null && t.buy_in > filters.buyInMax) return false; + if (filters.minOpenEntries != null) { + const open = t.max_players - t.registered; + if (open < filters.minOpenEntries) return false; + } + if (filters.blinds?.maxBigBlind != null) { + if (t.current_big_blind > filters.blinds.maxBigBlind) return false; + } + if (filters.startTimeAfter != null) { + if (sortTimeFor(t) < (filters.startTimeAfter / 1000)) return false; + } + return true; + }); +} + +export function sortTournaments( + tournaments: TournamentSummary[], + key: TournamentSortKey, + direction: SortDirection +): TournamentSummary[] { + const dir = direction === "asc" ? 1 : -1; + return [...tournaments].sort((a, b) => { + let cmp = 0; + switch (key) { + case "entries": + cmp = a.registered - b.registered; + break; + case "prizePool": + cmp = a.prize_pool - b.prize_pool; + break; + case "startTime": + cmp = sortTimeFor(a) - sortTimeFor(b); + break; + } + if (cmp === 0) cmp = a.name.localeCompare(b.name); + return cmp * dir; + }); +} + +export type RegistrationStatus = + | "open" + | "full" + | "in-progress" + | "closed"; + +export function registrationStatus(t: TournamentSummary): RegistrationStatus { + if (t.status === "registration") { + return t.registered >= t.max_players ? "full" : "open"; + } + if (t.status === "running") return "in-progress"; + return "closed"; +} + +export function registrationLabel(status: RegistrationStatus): string { + switch (status) { + case "open": + return "OPEN"; + case "full": + return "FULL"; + case "in-progress": + return "IN PROGRESS"; + case "closed": + return "CLOSED"; + } +} + +export function registrationColor(status: RegistrationStatus): string { + switch (status) { + case "open": + return "#27ae60"; + case "full": + return "#e74c3c"; + case "in-progress": + return "#f39c12"; + case "closed": + return "#7f8c8d"; + } +}