making funds work better and some refactor
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
This commit is contained in:
@@ -20,6 +20,7 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
isFund Boolean @default(false)
|
isFund Boolean @default(false)
|
||||||
|
isHidden Boolean @default(false) // hidden from leaderboards and public listings
|
||||||
|
|
||||||
positions Position[]
|
positions Position[]
|
||||||
trades Trade[]
|
trades Trade[]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default async function AdminOverviewPage() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
|
where: { isFund: false, isHidden: false },
|
||||||
orderBy: { balance: 'desc' },
|
orderBy: { balance: 'desc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
select: { id: true, username: true, balance: true, isAdmin: true },
|
select: { id: true, username: true, balance: true, isAdmin: true },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface UserData {
|
|||||||
balance: number
|
balance: number
|
||||||
researchPoints: number
|
researchPoints: number
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
isHidden: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminUserActions({ user }: { user: UserData }) {
|
export function AdminUserActions({ user }: { user: UserData }) {
|
||||||
@@ -17,6 +18,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [balance, setBalance] = useState(String(user.balance))
|
const [balance, setBalance] = useState(String(user.balance))
|
||||||
const [points, setPoints] = useState(String(user.researchPoints))
|
const [points, setPoints] = useState(String(user.researchPoints))
|
||||||
|
const [hidden, setHidden] = useState(user.isHidden)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
const [resetUrl, setResetUrl] = useState<string | null>(null)
|
||||||
const [lotteryReset, setLotteryReset] = useState(false)
|
const [lotteryReset, setLotteryReset] = useState(false)
|
||||||
@@ -31,6 +33,7 @@ export function AdminUserActions({ user }: { user: UserData }) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
balance: parseFloat(balance),
|
balance: parseFloat(balance),
|
||||||
researchPoints: parseInt(points, 10),
|
researchPoints: parseInt(points, 10),
|
||||||
|
isHidden: hidden,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
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"
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
|||||||
balance: true,
|
balance: true,
|
||||||
researchPoints: true,
|
researchPoints: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
isHidden: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
_count: { select: { trades: true, positions: true } },
|
_count: { select: { trades: true, positions: true } },
|
||||||
},
|
},
|
||||||
@@ -73,7 +74,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-surface-border">
|
<tbody className="divide-y divide-surface-border">
|
||||||
{users.map((user) => (
|
{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">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
|
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
|
||||||
@@ -84,6 +85,11 @@ export default async function AdminUsersPage({ searchParams }: Props) {
|
|||||||
admin
|
admin
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{user.isHidden && (
|
||||||
|
<span className="text-xs bg-slate-500/20 text-slate-400 px-1.5 rounded">
|
||||||
|
hidden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
|
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const schema = z.object({
|
|||||||
balance: z.number().min(0).optional(),
|
balance: z.number().min(0).optional(),
|
||||||
researchPoints: z.number().int().min(0).optional(),
|
researchPoints: z.number().int().min(0).optional(),
|
||||||
isAdmin: z.boolean().optional(),
|
isAdmin: z.boolean().optional(),
|
||||||
|
isHidden: z.boolean().optional(),
|
||||||
resetLotteryAt: z.boolean().optional(),
|
resetLotteryAt: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
|
|||||||
...rest,
|
...rest,
|
||||||
...(resetLotteryAt ? { lastLotteryAt: null } : {}),
|
...(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)
|
return NextResponse.json(updated)
|
||||||
|
|||||||
+229
-96
@@ -2,14 +2,15 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/lib/auth'
|
import { authOptions } from '@/lib/auth'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
import { formatCurrency } from '@/lib/utils'
|
||||||
|
import { calcFundNav } from '@/lib/pricing'
|
||||||
import Link from 'next/link'
|
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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
async function getLeaderboard() {
|
async function getLeaderboard() {
|
||||||
// Fetch all users with their open positions (to calculate net worth)
|
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
|
where: { isFund: false, isHidden: false },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
username: true,
|
||||||
@@ -52,10 +53,66 @@ async function getLeaderboard() {
|
|||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function LeaderboardPage() {
|
async function getFundLeaderboard() {
|
||||||
const [session, players] = await Promise.all([
|
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),
|
getServerSession(authOptions),
|
||||||
getLeaderboard(),
|
getLeaderboard(),
|
||||||
|
getFundLeaderboard(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
|
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
|
||||||
@@ -66,105 +123,181 @@ export default async function LeaderboardPage() {
|
|||||||
<Trophy className="h-7 w-7 text-amber-400" />
|
<Trophy className="h-7 w-7 text-amber-400" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Leaderboard</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session && myRank > 0 && (
|
{/* Tabs */}
|
||||||
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
|
<div className="flex gap-1 bg-surface-card border border-surface-border rounded-xl p-1 w-fit">
|
||||||
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
|
<Link
|
||||||
<span className="font-bold text-indigo-300">
|
href="/leaderboard?tab=players"
|
||||||
{formatCurrency(players[myRank - 1].netWorth)}
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</span>
|
tab === 'players' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
<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{' '}
|
||||||
|
<span className="font-bold text-indigo-300">
|
||||||
|
{formatCurrency(players[myRank - 1].netWorth)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
<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>
|
||||||
|
<span className="text-right">Net worth</span>
|
||||||
|
<span className="text-right hidden sm:block">Cash</span>
|
||||||
|
<span className="text-right hidden sm:block">Trades</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{players.length === 0 && (
|
||||||
|
<p className="text-center py-12 text-slate-500">No players yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y divide-surface-border">
|
||||||
|
{players.map((player, i) => {
|
||||||
|
const rank = i + 1
|
||||||
|
const isMe = session?.user.id === player.id
|
||||||
|
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={player.id}
|
||||||
|
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
|
||||||
|
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||||
|
{rankIcon ?? `#${rank}`}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/profile/${player.username}`}
|
||||||
|
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
|
||||||
|
>
|
||||||
|
{player.displayUsername ?? player.username}
|
||||||
|
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
|
||||||
|
</Link>
|
||||||
|
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
|
||||||
|
<span className="text-right text-slate-400 hidden sm:block">
|
||||||
|
{formatCurrency(player.balance)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-slate-400 hidden sm:block">
|
||||||
|
{player.tradeCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unrealized P&L podium */}
|
||||||
|
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Top unrealized gainers (from open positions)
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{players
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((p) => (
|
||||||
|
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
|
||||||
|
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
|
||||||
|
{p.displayUsername ?? p.username}
|
||||||
|
</Link>
|
||||||
|
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{p.unrealizedPnl >= 0 ? (
|
||||||
|
<span className="flex items-center justify-center gap-1">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
+{formatCurrency(p.unrealizedPnl)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center justify-center gap-1">
|
||||||
|
<TrendingDown className="h-3 w-3" />
|
||||||
|
{formatCurrency(p.unrealizedPnl)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
{tab === 'funds' && (
|
||||||
{/* Header */}
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
<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">
|
<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>#</span>
|
||||||
<span>Player</span>
|
<span>Fund</span>
|
||||||
<span className="text-right">Net worth</span>
|
<span className="text-right">Total value</span>
|
||||||
<span className="text-right hidden sm:block">Cash</span>
|
<span className="text-right hidden sm:block">NAV/share</span>
|
||||||
<span className="text-right hidden sm:block">Trades</span>
|
<span className="text-right hidden sm:block">Investors</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{players.length === 0 && (
|
|
||||||
<p className="text-center py-12 text-slate-500">No players yet.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="divide-y divide-surface-border">
|
|
||||||
{players.map((player, i) => {
|
|
||||||
const rank = i + 1
|
|
||||||
const isMe = session?.user.id === player.id
|
|
||||||
const rankIcon =
|
|
||||||
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={player.id}
|
|
||||||
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
|
|
||||||
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
|
|
||||||
} transition-colors`}
|
|
||||||
>
|
|
||||||
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
|
|
||||||
{rankIcon ?? `#${rank}`}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href={`/profile/${player.username}`}
|
|
||||||
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
|
|
||||||
>
|
|
||||||
{player.displayUsername ?? player.username}
|
|
||||||
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
|
|
||||||
</Link>
|
|
||||||
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
|
|
||||||
<span className="text-right text-slate-400 hidden sm:block">
|
|
||||||
{formatCurrency(player.balance)}
|
|
||||||
</span>
|
|
||||||
<span className="text-right text-slate-400 hidden sm:block">
|
|
||||||
{player.tradeCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unrealized P&L podium */}
|
|
||||||
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
|
|
||||||
<TrendingUp className="h-4 w-4" />
|
|
||||||
Top unrealized gainers (from open positions)
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{players
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((p) => (
|
|
||||||
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
|
|
||||||
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
|
|
||||||
{p.displayUsername ?? p.username}
|
|
||||||
</Link>
|
|
||||||
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
||||||
{p.unrealizedPnl >= 0 ? (
|
|
||||||
<span className="flex items-center justify-center gap-1">
|
|
||||||
<TrendingUp className="h-3 w-3" />
|
|
||||||
+{formatCurrency(p.unrealizedPnl)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center justify-center gap-1">
|
|
||||||
<TrendingDown className="h-3 w-3" />
|
|
||||||
{formatCurrency(p.unrealizedPnl)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-22
@@ -230,11 +230,7 @@ const schedulerWorker = new Worker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [waitingAfter, activeAfter] = await Promise.all([
|
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already in-flight)`)
|
||||||
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}`)
|
|
||||||
},
|
},
|
||||||
{ connection: redisOpts() },
|
{ 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) => {
|
priceWorker.on('stalled', (jobId) => {
|
||||||
console.warn(`[price-worker] stalled job ${jobId} — lock expired, will retry`)
|
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 ───────────────────────────────────────────────────────────
|
// ── Repeatable jobs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function setupRepeatableJobs() {
|
async function setupRepeatableJobs() {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user