This commit is contained in:
+58
-3
@@ -2,9 +2,13 @@ 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 { HashtagCard } from '@/components/HashtagCard'
|
import { HashtagCard } from '@/components/HashtagCard'
|
||||||
import { TrendingUp, Users, Hash } from 'lucide-react'
|
import { TrendingUp, Users, Hash, AlertTriangle } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { formatPnl, pnlColor } from '@/lib/utils'
|
import { formatPnl, pnlColor } from '@/lib/utils'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
||||||
|
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
@@ -59,10 +63,33 @@ async function getHoldings(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getZombieWarnings(userId: string) {
|
||||||
|
const threshold = Math.floor(ZOMBIE_ZERO_COUNT * 0.9)
|
||||||
|
const positions = await prisma.position.findMany({
|
||||||
|
where: { userId, shares: { gt: 0 }, hashtag: { zeroCount: { gte: threshold } } },
|
||||||
|
select: { hashtag: { select: { tag: true, displayTag: true, zeroCount: true } } },
|
||||||
|
})
|
||||||
|
return positions.map((p) => ({
|
||||||
|
tag: p.hashtag.tag,
|
||||||
|
displayTag: p.hashtag.displayTag,
|
||||||
|
zombieSince: formatDistanceToNow(
|
||||||
|
new Date(Date.now() - p.hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
),
|
||||||
|
zombieEta: formatDistanceToNow(
|
||||||
|
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - p.hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
||||||
|
{ addSuffix: true },
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions)
|
const session = await getServerSession(authOptions)
|
||||||
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] =
|
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings, zombieWarnings] =
|
||||||
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)])
|
await Promise.all([
|
||||||
|
getStats(),
|
||||||
|
session ? getHoldings(session.user.id) : Promise.resolve(null),
|
||||||
|
session ? getZombieWarnings(session.user.id) : Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -118,6 +145,34 @@ export default async function HomePage() {
|
|||||||
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
|
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zombie / at-risk position warnings */}
|
||||||
|
{zombieWarnings.length > 0 && (
|
||||||
|
<section className="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
|
||||||
|
<h2 className="font-semibold mb-1 flex items-center gap-2 text-amber-400">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
At-risk positions
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
|
The following hashtags have had no activity for an extended period and may be auto-liquidated soon.
|
||||||
|
Consider closing these positions manually.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{zombieWarnings.map((w) => (
|
||||||
|
<Link
|
||||||
|
key={w.tag}
|
||||||
|
href={`/hashtag/${w.tag}`}
|
||||||
|
title={`No activity for ${w.zombieSince}. Estimated liquidation ${w.zombieEta}.`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs bg-amber-500/10 border border-amber-500/20 hover:border-amber-400/50 text-amber-300 hover:text-amber-200 px-3 py-1.5 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
#{w.displayTag}
|
||||||
|
<span className="text-amber-500/70">· liquidates {w.zombieEta}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Holdings summary — biggest gain + biggest loss for signed-in users */}
|
{/* Holdings summary — biggest gain + biggest loss for signed-in users */}
|
||||||
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
|
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { authOptions } from '@/lib/auth'
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Coins, AlertTriangle } from 'lucide-react'
|
import { Coins } from 'lucide-react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
|
||||||
|
|
||||||
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
|
|
||||||
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -54,7 +50,6 @@ export default async function PositionsPage() {
|
|||||||
tag: true,
|
tag: true,
|
||||||
displayTag: true,
|
displayTag: true,
|
||||||
currentPrice: true,
|
currentPrice: true,
|
||||||
zeroCount: true,
|
|
||||||
priceHistory: {
|
priceHistory: {
|
||||||
orderBy: { recordedAt: 'asc' },
|
orderBy: { recordedAt: 'asc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
@@ -103,18 +98,6 @@ export default async function PositionsPage() {
|
|||||||
const currentValue = pos.hashtag.currentPrice * pos.shares
|
const currentValue = pos.hashtag.currentPrice * pos.shares
|
||||||
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
|
||||||
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price)
|
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price)
|
||||||
const isZombieWarning = pos.hashtag.zeroCount >= ZOMBIE_ZERO_COUNT * 0.9
|
|
||||||
const zombieSince = isZombieWarning
|
|
||||||
? formatDistanceToNow(
|
|
||||||
new Date(Date.now() - pos.hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
const zombieEta = isZombieWarning
|
|
||||||
? formatDistanceToNow(
|
|
||||||
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - pos.hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
|
|
||||||
{ addSuffix: true },
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -131,15 +114,6 @@ export default async function PositionsPage() {
|
|||||||
>
|
>
|
||||||
#{pos.hashtag.displayTag}
|
#{pos.hashtag.displayTag}
|
||||||
</Link>
|
</Link>
|
||||||
{isZombieWarning && zombieSince && zombieEta && (
|
|
||||||
<span
|
|
||||||
title={`No activity detected for ${zombieSince}. Estimated auto-liquidation ${zombieEta}.`}
|
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 border border-amber-500/20 mt-0.5"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
No activity for {zombieSince} · Liquidates {zombieEta}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
||||||
pos.positionType === 'LONG'
|
pos.positionType === 'LONG'
|
||||||
|
|||||||
Reference in New Issue
Block a user