Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion src/routes/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<number>`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 {
Expand Down Expand Up @@ -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<AdminUser[]>(loaderData.users);
const [stats, setStats] = useState<AdminStats>(loaderData.stats);
Expand Down Expand Up @@ -405,6 +455,47 @@ function AdminPage() {
<div className="admin-stats">
<StatCard label="Total Users" value={stats.totalUsers} />
<StatCard label="Users with Picks" value={stats.usersWithPicks} />
<StatCard label="Total Picks" value={stats.totalPredictions} />
</div>

<div className="most-picked-section">
<div className="most-picked-header">
<h2>Most Picked People</h2>
<span className="most-picked-subtitle">
Top {stats.mostPickedPeople.length} by total picks
</span>
</div>
{stats.mostPickedPeople.length === 0 ? (
<div className="most-picked-empty">No picks submitted yet.</div>
) : (
<ul className="most-picked-list">
{stats.mostPickedPeople.map((person, index) => (
<li key={person.playerId} className="most-picked-row">
<span className="most-picked-rank">#{index + 1}</span>
<div className="most-picked-player">
<img
src={person.playerPhoto}
alt={person.playerName}
className="most-picked-avatar"
/>
<span className="most-picked-name">{person.playerName}</span>
</div>
<div className="most-picked-metrics">
<span className="most-picked-metric">
<strong>{person.pickCount}</strong> picks
</span>
<span className="most-picked-metric">
<strong>{person.pickedByUsers}</strong> users
</span>
<span className="most-picked-metric percent">
<strong>{formatPercentage(person.pickSharePct)}</strong>{" "}
share
</span>
</div>
</li>
))}
</ul>
)}
</div>

{message && (
Expand Down
58 changes: 57 additions & 1 deletion src/routes/api/admin/users.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand Down Expand Up @@ -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<number>`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 }), {
Expand Down
138 changes: 138 additions & 0 deletions src/styles/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
============================================ */
Expand Down Expand Up @@ -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) {
Expand Down