feat: enhance trade feed with pagination and detailed trade information
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m29s

This commit is contained in:
2026-03-18 20:07:30 -04:00
parent 03ee361f29
commit 50b1b3472f
3 changed files with 136 additions and 3 deletions
+9 -1
View File
@@ -187,7 +187,15 @@ export default async function HomePage() {
{/* Recently traded */}
{recentTrades.length > 0 && (
<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">
{recentTrades.map(({ hashtag }) => hashtag && (
<HashtagCard
+112
View File
@@ -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
View File
@@ -238,13 +238,27 @@ for (const worker of [priceWorker, maintenanceWorker, schedulerWorker]) {
// ── Repeatable jobs ───────────────────────────────────────────────────────────
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
await schedulerQueue.add(
'trigger-sweep',
{},
{
repeat: { every: UPDATE_INTERVAL_MIN * 60 * 1000 },
jobId: 'price-sweep-repeatable',
},
)
@@ -254,7 +268,6 @@ async function setupRepeatableJobs() {
{},
{
repeat: { pattern: '5 0 * * *' },
jobId: 'daily-maintenance-repeatable',
},
)