This commit is contained in:
@@ -28,6 +28,7 @@ model User {
|
|||||||
managedFunds FundManager[]
|
managedFunds FundManager[]
|
||||||
fund HedgeFund?
|
fund HedgeFund?
|
||||||
fundInvestments FundInvestment[]
|
fundInvestments FundInvestment[]
|
||||||
|
portfolioHistory UserPortfolioHistory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model HedgeFund {
|
model HedgeFund {
|
||||||
@@ -143,6 +144,17 @@ model FundNavHistory {
|
|||||||
@@index([fundId, recordedAt])
|
@@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 {
|
model Position {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
|||||||
where: { hashtagId },
|
where: { hashtagId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
include: { user: { select: { username: true } } },
|
include: { user: { select: { username: true, displayUsername: true, isFund: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (trades.length === 0) return null
|
if (trades.length === 0) return null
|
||||||
@@ -230,12 +230,15 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
|
|||||||
>
|
>
|
||||||
{t.type.replace('_', ' ')}
|
{t.type.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<div className="flex items-center gap-1">
|
||||||
href={`/profile/${t.user.username}`}
|
{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"
|
className="text-slate-400 hover:text-slate-200"
|
||||||
>
|
>
|
||||||
{t.user.username}
|
{t.user.displayUsername ?? t.user.username}
|
||||||
</a>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
|
<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 { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react'
|
||||||
import ChangePasswordForm from './ChangePasswordForm'
|
import ChangePasswordForm from './ChangePasswordForm'
|
||||||
import AccountSettingsForm from './AccountSettingsForm'
|
import AccountSettingsForm from './AccountSettingsForm'
|
||||||
|
import { PriceChart } from '@/components/PriceChart'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -79,6 +80,15 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
})
|
})
|
||||||
const lotteryWinnings = lotteryAggregate._sum.profit ?? 0
|
const lotteryWinnings = lotteryAggregate._sum.profit ?? 0
|
||||||
const lotteryCount = lotteryAggregate._count
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -140,6 +150,17 @@ export default async function ProfilePage({ params }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Positions */}
|
||||||
{user.positions.length > 0 && (
|
{user.positions.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -281,6 +281,16 @@ const maintenanceWorker = new Worker(
|
|||||||
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
|
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
|
||||||
`inactive: ${deletedInactive.count} rows removed (>${inactiveHours}h)`,
|
`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() },
|
{ connection: redisOpts() },
|
||||||
)
|
)
|
||||||
@@ -329,6 +339,39 @@ const fundNavSnapshotWorker = new Worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[fund-nav] snapshotted ${funds.length} fund(s)`)
|
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() },
|
{ connection: redisOpts() },
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user