From 14c8ca3c046cd07a2321281749120545ebe8a7a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 25 Feb 2026 22:49:35 +0000 Subject: [PATCH] Add most-picked people stats to admin dashboard Co-authored-by: Wes Bos --- src/routes/admin.tsx | 93 ++++++++++++++++++++++- src/routes/api/admin/users.ts | 58 +++++++++++++- src/styles/admin.css | 138 ++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 2 deletions(-) diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 5f8720e..d1f0ab6 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -11,10 +11,15 @@ import { STAGE_CONFIG, } from "@/lib/simulation"; import { deleteUserFn, generateTestUserFn } from "@/lib/users.server"; -import type { AdminStats, AdminUser } from "@/routes/api/admin/users"; +import type { + AdminStats, + AdminUser, + MostPickedPersonStat, +} from "@/routes/api/admin/users"; import "@/styles/admin.css"; const PAGE_SIZE = 20; +const TOP_PICKED_LIMIT = 5; const adminDataInputSchema = z.object({ page: z.number().int().positive().default(1), @@ -65,6 +70,7 @@ const getAdminDataFn = createServerFn({ method: "GET" }) const { isAdminUser } = await import("@/lib/admin"); const { count, desc, like, sql } = await import("drizzle-orm"); const schema = await import("@/db/schema"); + const { players } = await import("@/data/players"); const page = data.page; const searchRaw = data.search.trim(); @@ -185,9 +191,52 @@ const getAdminDataFn = createServerFn({ method: "GET" }) }) .from(schema.userPrediction); + const pickCountsByPerson = await db + .select({ + predictedWinnerId: schema.userPrediction.predictedWinnerId, + pickCount: count(), + pickedByUsers: sql`COUNT(DISTINCT ${schema.userPrediction.userId})`, + }) + .from(schema.userPrediction) + .groupBy(schema.userPrediction.predictedWinnerId); + + const totalPredictions = pickCountsByPerson.reduce( + (sum, row) => sum + row.pickCount, + 0, + ); + + const playerMap = new Map(players.map((player) => [player.id, player])); + const mostPickedPeople: MostPickedPersonStat[] = pickCountsByPerson + .flatMap((row) => { + const player = playerMap.get(row.predictedWinnerId); + if (!player) return []; + return [ + { + playerId: player.id, + playerName: player.name, + playerPhoto: player.photo, + pickCount: row.pickCount, + pickedByUsers: row.pickedByUsers, + pickSharePct: + totalPredictions > 0 + ? (row.pickCount / totalPredictions) * 100 + : 0, + }, + ]; + }) + .sort((a, b) => { + if (b.pickCount !== a.pickCount) { + return b.pickCount - a.pickCount; + } + return a.playerName.localeCompare(b.playerName); + }) + .slice(0, TOP_PICKED_LIMIT); + const stats: AdminStats = { totalUsers: allUsersCount, usersWithPicks, + totalPredictions, + mostPickedPeople, }; return { @@ -249,6 +298,7 @@ function StatCard({ label, value }: { label: string; value: number }) { function AdminPage() { const loaderData = Route.useLoaderData(); + const formatPercentage = (value: number) => `${value.toFixed(1)}%`; const [users, setUsers] = useState(loaderData.users); const [stats, setStats] = useState(loaderData.stats); @@ -405,6 +455,47 @@ function AdminPage() {
+ +
+ +
+
+

Most Picked People

+ + Top {stats.mostPickedPeople.length} by total picks + +
+ {stats.mostPickedPeople.length === 0 ? ( +
No picks submitted yet.
+ ) : ( +
    + {stats.mostPickedPeople.map((person, index) => ( +
  • + #{index + 1} +
    + {person.playerName} + {person.playerName} +
    +
    + + {person.pickCount} picks + + + {person.pickedByUsers} users + + + {formatPercentage(person.pickSharePct)}{" "} + share + +
    +
  • + ))} +
+ )}
{message && ( diff --git a/src/routes/api/admin/users.ts b/src/routes/api/admin/users.ts index 6230374..cf60b08 100644 --- a/src/routes/api/admin/users.ts +++ b/src/routes/api/admin/users.ts @@ -1,6 +1,7 @@ import { env } from "cloudflare:workers"; import { createFileRoute } from "@tanstack/react-router"; -import { count, desc } from "drizzle-orm"; +import { count, desc, sql } from "drizzle-orm"; +import { players } from "@/data/players"; import { createDb } from "@/db"; import * as schema from "@/db/schema"; import { isAdminUser } from "@/lib/admin"; @@ -15,11 +16,24 @@ export type AdminUser = { totalScore: number; }; +export type MostPickedPersonStat = { + playerId: string; + playerName: string; + playerPhoto: string; + pickCount: number; + pickedByUsers: number; + pickSharePct: number; +}; + export type AdminStats = { totalUsers: number; usersWithPicks: number; + totalPredictions: number; + mostPickedPeople: MostPickedPersonStat[]; }; +const TOP_PICKED_LIMIT = 5; + export const Route = createFileRoute("/api/admin/users")({ server: { handlers: { @@ -85,9 +99,51 @@ export const Route = createFileRoute("/api/admin/users")({ // Calculate stats const usersWithPicks = predictionCounts.length; + const playerMap = new Map(players.map((player) => [player.id, player])); + const pickCountsByPerson = await db + .select({ + predictedWinnerId: schema.userPrediction.predictedWinnerId, + pickCount: count(), + pickedByUsers: sql`COUNT(DISTINCT ${schema.userPrediction.userId})`, + }) + .from(schema.userPrediction) + .groupBy(schema.userPrediction.predictedWinnerId); + + const totalPredictions = pickCountsByPerson.reduce( + (sum, row) => sum + row.pickCount, + 0, + ); + const mostPickedPeople: MostPickedPersonStat[] = pickCountsByPerson + .flatMap((row) => { + const player = playerMap.get(row.predictedWinnerId); + if (!player) return []; + return [ + { + playerId: player.id, + playerName: player.name, + playerPhoto: player.photo, + pickCount: row.pickCount, + pickedByUsers: row.pickedByUsers, + pickSharePct: + totalPredictions > 0 + ? (row.pickCount / totalPredictions) * 100 + : 0, + }, + ]; + }) + .sort((a, b) => { + if (b.pickCount !== a.pickCount) { + return b.pickCount - a.pickCount; + } + return a.playerName.localeCompare(b.playerName); + }) + .slice(0, TOP_PICKED_LIMIT); + const stats: AdminStats = { totalUsers: users.length, usersWithPicks, + totalPredictions, + mostPickedPeople, }; return new Response(JSON.stringify({ users: adminUsers, stats }), { diff --git a/src/styles/admin.css b/src/styles/admin.css index 17bae43..c50ffca 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -120,6 +120,135 @@ text-shadow: none; } +/* ============================================ + Most Picked People + ============================================ */ + +.most-picked-section { + margin-bottom: 1.5rem; + background: var(--white); + border: 4px solid var(--black); +} + +.most-picked-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem; + background: var(--black); + color: var(--yellow); + flex-wrap: wrap; +} + +.most-picked-header h2 { + margin: 0; + font-family: var(--font-block); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.most-picked-subtitle { + font-family: var(--font-block); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.most-picked-list { + list-style: none; + margin: 0; + padding: 0; +} + +.most-picked-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.9rem 1rem; + border-bottom: 2px solid var(--black); +} + +.most-picked-row:last-child { + border-bottom: none; +} + +.most-picked-rank { + min-width: 48px; + padding: 0.35rem 0.45rem; + background: var(--yellow); + border: 2px solid var(--black); + font-family: var(--font-block); + font-size: 0.68rem; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.most-picked-player { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 220px; +} + +.most-picked-avatar { + width: 2rem; + height: 2rem; + border: 2px solid var(--black); + object-fit: cover; + background: var(--beige); +} + +.most-picked-name { + font-family: var(--font-block); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--black); +} + +.most-picked-metrics { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; +} + +.most-picked-metric { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.45rem; + border: 2px solid var(--black); + background: var(--beige); + font-family: var(--font-block); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--black); +} + +.most-picked-metric strong { + font-family: "DSEG7Classic", monospace; + font-size: 0.85rem; + color: var(--orange); +} + +.most-picked-metric.percent { + background: var(--yellow); +} + +.most-picked-empty { + padding: 1.1rem 1rem; + font-family: var(--font-block); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #666; +} + /* ============================================ Actions & Search ============================================ */ @@ -532,6 +661,15 @@ .admin-table td:nth-child(5) { display: none; } + + .most-picked-row { + align-items: flex-start; + flex-direction: column; + } + + .most-picked-player { + min-width: 0; + } } @media (max-width: 480px) {