feat: enhance trade feed with pagination and detailed trade information
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m29s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m29s
This commit is contained in:
+9
-1
@@ -187,7 +187,15 @@ export default async function HomePage() {
|
|||||||
{/* Recently traded */}
|
{/* Recently traded */}
|
||||||
{recentTrades.length > 0 && (
|
{recentTrades.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold mb-4">Recently traded</h2>
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
Recently traded
|
||||||
|
<Link
|
||||||
|
href="/trades"
|
||||||
|
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
Full feed →
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
{recentTrades.map(({ hashtag }) => hashtag && (
|
{recentTrades.map(({ hashtag }) => hashtag && (
|
||||||
<HashtagCard
|
<HashtagCard
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Activity } from 'lucide-react'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: { page?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GlobalTradesPage({ searchParams }: PageProps) {
|
||||||
|
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
|
||||||
|
|
||||||
|
const [total, trades] = await Promise.all([
|
||||||
|
prisma.trade.count({ where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } } }),
|
||||||
|
prisma.trade.findMany({
|
||||||
|
where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
skip: (page - 1) * PAGE_SIZE,
|
||||||
|
include: {
|
||||||
|
hashtag: { select: { tag: true, displayTag: true } },
|
||||||
|
user: { select: { username: true, displayUsername: true, isFund: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Activity className="h-6 w-6 text-indigo-400" />
|
||||||
|
<h1 className="text-2xl font-bold">Trade Feed</h1>
|
||||||
|
<span className="text-slate-500 text-sm">({total.toLocaleString()} trades)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||||
|
<div className="divide-y divide-surface-border">
|
||||||
|
{trades.map((t) => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
|
||||||
|
t.type.startsWith('BUY')
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400'
|
||||||
|
: 'bg-red-500/15 text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.type.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{t.user.isFund ? (
|
||||||
|
<span className="text-xs text-indigo-400">🏦</span>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
|
||||||
|
className="text-slate-300 hover:text-white"
|
||||||
|
>
|
||||||
|
{t.user.displayUsername ?? t.user.username}
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-600">·</span>
|
||||||
|
<Link href={`/hashtag/${t.hashtag!.tag}`} className="text-indigo-300 hover:text-indigo-200">
|
||||||
|
#{t.hashtag!.displayTag}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{formatDistanceToNow(t.createdAt, { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
|
||||||
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{page > 1 && (
|
||||||
|
<Link
|
||||||
|
href={`/trades?page=${page - 1}`}
|
||||||
|
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="text-slate-500 text-sm">Page {page} of {totalPages}</span>
|
||||||
|
{page < totalPages && (
|
||||||
|
<Link
|
||||||
|
href={`/trades?page=${page + 1}`}
|
||||||
|
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
-2
@@ -238,13 +238,27 @@ for (const worker of [priceWorker, maintenanceWorker, schedulerWorker]) {
|
|||||||
// ── Repeatable jobs ───────────────────────────────────────────────────────────
|
// ── Repeatable jobs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function setupRepeatableJobs() {
|
async function setupRepeatableJobs() {
|
||||||
|
// Always wipe existing repeatable registrations first so that:
|
||||||
|
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
|
||||||
|
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly
|
||||||
|
const [existingScheduler, existingMaintenance] = await Promise.all([
|
||||||
|
schedulerQueue.getRepeatableJobs(),
|
||||||
|
maintenanceQueue.getRepeatableJobs(),
|
||||||
|
])
|
||||||
|
await Promise.all([
|
||||||
|
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
|
||||||
|
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
|
||||||
|
])
|
||||||
|
if (existingScheduler.length || existingMaintenance.length) {
|
||||||
|
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance repeatable(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
// Price update sweep — every N minutes
|
// Price update sweep — every N minutes
|
||||||
await schedulerQueue.add(
|
await schedulerQueue.add(
|
||||||
'trigger-sweep',
|
'trigger-sweep',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { every: UPDATE_INTERVAL_MIN * 60 * 1000 },
|
repeat: { every: UPDATE_INTERVAL_MIN * 60 * 1000 },
|
||||||
jobId: 'price-sweep-repeatable',
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -254,7 +268,6 @@ async function setupRepeatableJobs() {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
repeat: { pattern: '5 0 * * *' },
|
repeat: { pattern: '5 0 * * *' },
|
||||||
jobId: 'daily-maintenance-repeatable',
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user