making funds work better and some refactor
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s

This commit is contained in:
2026-03-18 22:07:21 -04:00
parent 1ab8763089
commit 39dd864245
8 changed files with 262 additions and 121 deletions
+1
View File
@@ -20,6 +20,7 @@ model User {
updatedAt DateTime @updatedAt
isFund Boolean @default(false)
isHidden Boolean @default(false) // hidden from leaderboards and public listings
positions Position[]
trades Trade[]
+1
View File
@@ -20,6 +20,7 @@ export default async function AdminOverviewPage() {
},
}),
prisma.user.findMany({
where: { isFund: false, isHidden: false },
orderBy: { balance: 'desc' },
take: 10,
select: { id: true, username: true, balance: true, isAdmin: true },
+20
View File
@@ -10,6 +10,7 @@ interface UserData {
balance: number
researchPoints: number
isAdmin: boolean
isHidden: boolean
}
export function AdminUserActions({ user }: { user: UserData }) {
@@ -17,6 +18,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
const [open, setOpen] = useState(false)
const [balance, setBalance] = useState(String(user.balance))
const [points, setPoints] = useState(String(user.researchPoints))
const [hidden, setHidden] = useState(user.isHidden)
const [loading, setLoading] = useState(false)
const [resetUrl, setResetUrl] = useState<string | null>(null)
const [lotteryReset, setLotteryReset] = useState(false)
@@ -31,6 +33,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
body: JSON.stringify({
balance: parseFloat(balance),
researchPoints: parseInt(points, 10),
isHidden: hidden,
}),
})
const data = await res.json()
@@ -113,6 +116,23 @@ export function AdminUserActions({ user }: { user: UserData }) {
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-slate-400">Hidden from leaderboards</label>
<p className="text-xs text-slate-500">Excludes this user from public rankings</p>
</div>
<button
type="button"
onClick={() => setHidden((h) => !h)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
hidden ? 'bg-slate-600' : 'bg-indigo-600'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
hidden ? 'translate-x-1' : 'translate-x-6'
}`} />
</button>
</div>
</div>
{error && (
+7 -1
View File
@@ -30,6 +30,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
balance: true,
researchPoints: true,
isAdmin: true,
isHidden: true,
createdAt: true,
_count: { select: { trades: true, positions: true } },
},
@@ -73,7 +74,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
</thead>
<tbody className="divide-y divide-surface-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-surface-hover transition-colors">
<tr key={user.id} className={`hover:bg-surface-hover transition-colors ${user.isHidden ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
@@ -84,6 +85,11 @@ export default async function AdminUsersPage({ searchParams }: Props) {
admin
</span>
)}
{user.isHidden && (
<span className="text-xs bg-slate-500/20 text-slate-400 px-1.5 rounded">
hidden
</span>
)}
</div>
</td>
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
+2 -1
View File
@@ -8,6 +8,7 @@ const schema = z.object({
balance: z.number().min(0).optional(),
researchPoints: z.number().int().min(0).optional(),
isAdmin: z.boolean().optional(),
isHidden: z.boolean().optional(),
resetLotteryAt: z.boolean().optional(),
})
@@ -31,7 +32,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
...rest,
...(resetLotteryAt ? { lastLotteryAt: null } : {}),
},
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true },
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true, isHidden: true },
})
return NextResponse.json(updated)
+141 -8
View File
@@ -2,14 +2,15 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { formatCurrency } from '@/lib/utils'
import { calcFundNav } from '@/lib/pricing'
import Link from 'next/link'
import { Trophy, TrendingUp, TrendingDown } from 'lucide-react'
import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
export const dynamic = 'force-dynamic'
async function getLeaderboard() {
// Fetch all users with their open positions (to calculate net worth)
const users = await prisma.user.findMany({
where: { isFund: false, isHidden: false },
select: {
id: true,
username: true,
@@ -52,10 +53,66 @@ async function getLeaderboard() {
.slice(0, 50)
}
export default async function LeaderboardPage() {
const [session, players] = await Promise.all([
async function getFundLeaderboard() {
const funds = await prisma.hedgeFund.findMany({
include: {
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
positionType: true,
shares: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
managers: { select: { userId: true } },
_count: { select: { investments: true } },
},
orderBy: { createdAt: 'asc' },
})
return funds
.map((f) => {
const portfolioValue = f.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
return sum + val
}, 0)
const totalValue = f.user.balance + portfolioValue
const nav = calcFundNav(totalValue, f.sharesOutstanding)
return {
id: f.id,
name: f.name,
slug: f.slug,
cash: f.user.balance,
portfolioValue,
totalValue,
nav,
sharesOutstanding: f.sharesOutstanding,
managerCount: f.managers.length,
investorCount: f._count.investments,
}
})
.sort((a, b) => b.totalValue - a.totalValue)
}
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: { tab?: string }
}) {
const tab = searchParams.tab === 'funds' ? 'funds' : 'players'
const [session, players, funds] = await Promise.all([
getServerSession(authOptions),
getLeaderboard(),
getFundLeaderboard(),
])
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
@@ -66,10 +123,37 @@ export default async function LeaderboardPage() {
<Trophy className="h-7 w-7 text-amber-400" />
<div>
<h1 className="text-2xl font-bold">Leaderboard</h1>
<p className="text-sm text-slate-400">Top 50 players by net worth (cash + open positions)</p>
<p className="text-sm text-slate-400">Ranked by net worth (cash + open positions)</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-surface-card border border-surface-border rounded-xl p-1 w-fit">
<Link
href="/leaderboard?tab=players"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'players' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Users className="h-4 w-4" />
Players
</Link>
<Link
href="/leaderboard?tab=funds"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'funds' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Building2 className="h-4 w-4" />
Hedge Funds
{funds.length > 0 && (
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{funds.length}</span>
)}
</Link>
</div>
{tab === 'players' && (
<>
{session && myRank > 0 && (
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
@@ -80,7 +164,6 @@ export default async function LeaderboardPage() {
)}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Player</span>
@@ -97,8 +180,7 @@ export default async function LeaderboardPage() {
{players.map((player, i) => {
const rank = i + 1
const isMe = session?.user.id === player.id
const rankIcon =
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
@@ -165,6 +247,57 @@ export default async function LeaderboardPage() {
</div>
</section>
)}
</>
)}
{tab === 'funds' && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Fund</span>
<span className="text-right">Total value</span>
<span className="text-right hidden sm:block">NAV/share</span>
<span className="text-right hidden sm:block">Investors</span>
</div>
{funds.length === 0 && (
<p className="text-center py-12 text-slate-500">No funds yet.</p>
)}
<div className="divide-y divide-surface-border">
{funds.map((fund, i) => {
const rank = i + 1
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
key={fund.id}
className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 items-center px-4 py-3 text-sm hover:bg-surface-hover transition-colors"
>
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
{rankIcon ?? `#${rank}`}
</span>
<Link
href={`/fund/${fund.slug}`}
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
>
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
{fund.name}
</Link>
<span className="text-right font-bold">{formatCurrency(fund.totalValue)}</span>
<span className="text-right text-slate-400 hidden sm:block">
{formatCurrency(fund.nav)}
</span>
<span className="text-right text-slate-400 hidden sm:block">
{fund.investorCount}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+1 -22
View File
@@ -230,11 +230,7 @@ const schedulerWorker = new Worker(
)
}
const [waitingAfter, activeAfter] = await Promise.all([
priceUpdateQueue.getWaitingCount(),
priceUpdateQueue.getActiveCount(),
])
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already waiting) — queue now: waiting=${waitingAfter} active=${activeAfter}`)
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already in-flight)`)
},
{ connection: redisOpts() },
)
@@ -251,27 +247,10 @@ for (const [name, worker] of [['price', priceWorker], ['maintenance', maintenanc
})
}
// Diagnostic: log when price jobs complete or stall
priceWorker.on('active', (job) => {
console.log(`[price-worker] active (picked up) job ${job.id} (${job.data?.tag})`)
})
priceWorker.on('completed', (job) => {
console.log(`[price-worker] completed job ${job.id} (${job.data?.tag})`)
})
priceWorker.on('stalled', (jobId) => {
console.warn(`[price-worker] stalled job ${jobId} — lock expired, will retry`)
})
// Log queue depths every 30 s so we can see if jobs are piling up or vanishing
setInterval(async () => {
const [waiting, active, failed] = await Promise.all([
priceUpdateQueue.getWaitingCount(),
priceUpdateQueue.getActiveCount(),
priceUpdateQueue.getFailedCount(),
])
console.log(`[price-queue] waiting=${waiting} active=${active} failed=${failed}`)
}, 30_000)
// ── Repeatable jobs ───────────────────────────────────────────────────────────
async function setupRepeatableJobs() {
+1 -1
View File
File diff suppressed because one or more lines are too long