This commit is contained in:
@@ -28,6 +28,7 @@ model User {
|
||||
managedFunds FundManager[]
|
||||
fund HedgeFund?
|
||||
fundInvestments FundInvestment[]
|
||||
portfolioHistory UserPortfolioHistory[]
|
||||
}
|
||||
|
||||
model HedgeFund {
|
||||
@@ -143,6 +144,17 @@ model FundNavHistory {
|
||||
@@index([fundId, recordedAt])
|
||||
}
|
||||
|
||||
model UserPortfolioHistory {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
totalValue Float
|
||||
portfolioValue Float
|
||||
recordedAt DateTime @default(now())
|
||||
|
||||
@@index([userId, recordedAt])
|
||||
}
|
||||
|
||||
model Position {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
@@ -209,7 +209,7 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
||||
where: { hashtagId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
include: { user: { select: { username: true } } },
|
||||
include: { user: { select: { username: true, displayUsername: true, isFund: true } } },
|
||||
})
|
||||
|
||||
if (trades.length === 0) return null
|
||||
@@ -230,12 +230,15 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
||||
>
|
||||
{t.type.replace('_', ' ')}
|
||||
</span>
|
||||
<a
|
||||
href={`/profile/${t.user.username}`}
|
||||
className="text-slate-400 hover:text-slate-200"
|
||||
>
|
||||
{t.user.username}
|
||||
</a>
|
||||
<div className="flex items-center gap-1">
|
||||
{t.user.isFund && <span className="text-indigo-400">🏦</span>}
|
||||
<Link
|
||||
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
|
||||
className="text-slate-400 hover:text-slate-200"
|
||||
>
|
||||
{t.user.displayUsername ?? t.user.username}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import Link from 'next/link'
|
||||
import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
import AccountSettingsForm from './AccountSettingsForm'
|
||||
import { PriceChart } from '@/components/PriceChart'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -79,6 +80,15 @@ export default async function ProfilePage({ params }: Props) {
|
||||
})
|
||||
const lotteryWinnings = lotteryAggregate._sum.profit ?? 0
|
||||
const lotteryCount = lotteryAggregate._count
|
||||
|
||||
const portfolioHistory = await prisma.userPortfolioHistory.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
orderBy: { recordedAt: 'asc' },
|
||||
select: { totalValue: true, recordedAt: true },
|
||||
})
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
@@ -140,6 +150,17 @@ export default async function ProfilePage({ params }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Portfolio value chart */}
|
||||
{portfolioHistory.length > 0 && (
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-3">Portfolio Value — Last 7 Days</h2>
|
||||
<PriceChart
|
||||
data={portfolioHistory.map((p) => ({ price: p.totalValue, recordedAt: p.recordedAt.toISOString() }))}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Positions */}
|
||||
{user.positions.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -281,6 +281,16 @@ const maintenanceWorker = new Worker(
|
||||
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
|
||||
`inactive: ${deletedInactive.count} rows removed (>${inactiveHours}h)`,
|
||||
)
|
||||
|
||||
// ── Snapshot history pruning (7 days for both) ─────────────────────────
|
||||
const snapshotCutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const [deletedFundNav, deletedUserPortfolio] = await Promise.all([
|
||||
prisma.fundNavHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
|
||||
prisma.userPortfolioHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
|
||||
])
|
||||
console.log(
|
||||
`[maintenance] pruned snapshots — fund NAV: ${deletedFundNav.count} rows, user portfolio: ${deletedUserPortfolio.count} rows (>7d)`,
|
||||
)
|
||||
},
|
||||
{ connection: redisOpts() },
|
||||
)
|
||||
@@ -329,6 +339,39 @@ const fundNavSnapshotWorker = new Worker(
|
||||
}
|
||||
|
||||
console.log(`[fund-nav] snapshotted ${funds.length} fund(s)`)
|
||||
|
||||
// ── User portfolio snapshots ──────────────────────────────────────────
|
||||
const regularUsers = await prisma.user.findMany({
|
||||
where: { isFund: false },
|
||||
select: {
|
||||
id: true,
|
||||
balance: true,
|
||||
positions: {
|
||||
where: { shares: { gt: 0 } },
|
||||
select: {
|
||||
shares: true,
|
||||
positionType: true,
|
||||
avgBuyPrice: true,
|
||||
hashtag: { select: { currentPrice: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const user of regularUsers) {
|
||||
const portfolioValue = 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 = user.balance + portfolioValue
|
||||
await prisma.userPortfolioHistory.create({
|
||||
data: { userId: user.id, totalValue, portfolioValue },
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[fund-nav] snapshotted ${regularUsers.length} user portfolio(s)`)
|
||||
},
|
||||
{ connection: redisOpts() },
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user