move warning banner
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s

This commit is contained in:
2026-03-19 18:56:34 -04:00
parent c3b0055572
commit d55e3dfef2
2 changed files with 59 additions and 30 deletions
+58 -3
View File
@@ -2,9 +2,13 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
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 { 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 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() {
const session = await getServerSession(authOptions)
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] =
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)])
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings, zombieWarnings] =
await Promise.all([
getStats(),
session ? getHoldings(session.user.id) : Promise.resolve(null),
session ? getZombieWarnings(session.user.id) : Promise.resolve([]),
])
return (
<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()} />
</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 && (holdings.biggestGain ?? holdings.biggestLoss) && (
<section>
+1 -27
View File
@@ -4,11 +4,7 @@ import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
import Link from 'next/link'
import { Coins, AlertTriangle } 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)
import { Coins } from 'lucide-react'
export const dynamic = 'force-dynamic'
@@ -54,7 +50,6 @@ export default async function PositionsPage() {
tag: true,
displayTag: true,
currentPrice: true,
zeroCount: true,
priceHistory: {
orderBy: { recordedAt: 'asc' },
take: 20,
@@ -103,18 +98,6 @@ export default async function PositionsPage() {
const currentValue = pos.hashtag.currentPrice * pos.shares
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
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 (
<div
@@ -131,15 +114,6 @@ export default async function PositionsPage() {
>
#{pos.hashtag.displayTag}
</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
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
pos.positionType === 'LONG'