feat: implement sortable positions table with dynamic sorting and display enhancements
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s

This commit is contained in:
2026-03-19 21:04:13 -04:00
parent 5c853bd1ee
commit 82b1953c8b
+121 -64
View File
@@ -4,7 +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 } from 'lucide-react'
import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
export const dynamic = 'force-dynamic'
@@ -37,13 +37,48 @@ function Sparkline({ prices }: { prices: number[] }) {
)
}
export default async function PositionsPage() {
type SortKey = 'hashtag' | 'shares' | 'avgBuy' | 'current' | 'costBasis' | 'value' | 'pnl'
type SortDir = 'asc' | 'desc'
function SortHeader({
col,
label,
currentSort,
currentDir,
right = false,
className,
}: {
col: SortKey
label: string
currentSort: SortKey
currentDir: SortDir
right?: boolean
className?: string
}) {
const isActive = currentSort === col
const nextDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
const Icon = isActive ? (currentDir === 'desc' ? ChevronDown : ChevronUp) : ChevronsUpDown
return (
<Link
href={`?sort=${col}&dir=${nextDir}`}
className={`flex items-center gap-1 text-xs uppercase tracking-wider hover:text-slate-300 transition-colors ${isActive ? 'text-indigo-400' : 'text-slate-500'} ${right ? 'justify-end' : ''} ${className ?? ''}`}
>
<span>{label}</span>
<Icon className={`h-3 w-3 shrink-0${isActive ? '' : ' opacity-40'}`} />
</Link>
)
}
export default async function PositionsPage({
searchParams,
}: {
searchParams: { sort?: string; dir?: string }
}) {
const session = await getServerSession(authOptions)
if (!session) redirect('/auth/signin')
const positions = await prisma.position.findMany({
const rawPositions = await prisma.position.findMany({
where: { userId: session.user.id, shares: { gt: 0 } },
orderBy: { updatedAt: 'desc' },
include: {
hashtag: {
select: {
@@ -60,6 +95,39 @@ export default async function PositionsPage() {
},
})
const positions = rawPositions.map((pos) => {
const pnl =
pos.positionType === 'LONG'
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
const costBasis = pos.avgBuyPrice * pos.shares
const currentValue = pos.hashtag.currentPrice * pos.shares
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price)
return { ...pos, pnl, costBasis, currentValue, pnlPct, sparkPrices }
})
const validSorts = new Set<string>(['hashtag', 'shares', 'avgBuy', 'current', 'costBasis', 'value', 'pnl'])
const sortKey: SortKey = validSorts.has(searchParams.sort ?? '') ? (searchParams.sort as SortKey) : 'pnl'
const sortDir: SortDir = searchParams.dir === 'asc' ? 'asc' : 'desc'
positions.sort((a, b) => {
let av: number | string
let bv: number | string
switch (sortKey) {
case 'hashtag': av = a.hashtag.displayTag.toLowerCase(); bv = b.hashtag.displayTag.toLowerCase(); break
case 'shares': av = a.shares; bv = b.shares; break
case 'avgBuy': av = a.avgBuyPrice; bv = b.avgBuyPrice; break
case 'current': av = a.hashtag.currentPrice; bv = b.hashtag.currentPrice; break
case 'costBasis': av = a.costBasis; bv = b.costBasis; break
case 'value': av = a.currentValue; bv = b.currentValue; break
default: av = a.pnl; bv = b.pnl; break
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
@@ -78,72 +146,61 @@ export default async function PositionsPage() {
) : (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header row */}
<div className="grid grid-cols-[1fr_auto_auto] sm:grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-slate-500 uppercase tracking-wider border-b border-surface-border">
<span>Hashtag</span>
<span className="text-right">Shares</span>
<span className="hidden sm:block text-right">Avg buy</span>
<span className="hidden sm:block text-right">Current</span>
<span className="hidden sm:block text-right">Cost basis</span>
<span className="hidden sm:block text-right">Value</span>
<span className="text-right">P&amp;L</span>
<div className="grid grid-cols-[1fr_auto_auto] sm:grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 px-4 py-2 border-b border-surface-border">
<SortHeader col="hashtag" label="Hashtag" currentSort={sortKey} currentDir={sortDir} />
<SortHeader col="shares" label="Shares" currentSort={sortKey} currentDir={sortDir} right />
<SortHeader col="avgBuy" label="Avg buy" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="current" label="Current" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="costBasis" label="Cost basis" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="value" label="Value" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="pnl" label="P&L" currentSort={sortKey} currentDir={sortDir} right />
</div>
<div className="divide-y divide-surface-border">
{positions.map((pos) => {
const pnl =
pos.positionType === 'LONG'
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
const costBasis = pos.avgBuyPrice * pos.shares
const currentValue = pos.hashtag.currentPrice * pos.shares
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price)
return (
<div
key={pos.id}
className="grid grid-cols-[1fr_auto_auto] sm:grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 items-center px-4 py-3"
>
{/* Hashtag + type badge (+ sparkline on desktop) */}
<div className="flex items-center gap-3 min-w-0">
<div className="hidden sm:block shrink-0">
<Sparkline prices={sparkPrices} />
</div>
<div className="min-w-0">
<Link
href={`/hashtag/${pos.hashtag.tag}`}
className="font-medium hover:text-indigo-300 truncate block"
>
#{pos.hashtag.displayTag}
</Link>
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
pos.positionType === 'LONG'
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{pos.positionType}
</span>
</div>
{positions.map((pos) => (
<div
key={pos.id}
className="grid grid-cols-[1fr_auto_auto] sm:grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 items-center px-4 py-3"
>
{/* Hashtag + type badge (+ sparkline on desktop) */}
<div className="flex items-center gap-3 min-w-0">
<div className="hidden sm:block shrink-0">
<Sparkline prices={pos.sparkPrices} />
</div>
<span className="text-right text-sm">{formatNumber(pos.shares)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(costBasis)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(currentValue)}</span>
<div className="text-right">
<p className={`text-sm font-medium ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
<p className={`text-xs ${pnlColor(pnlPct)}`}>
{pnlPct >= 0 ? '+' : ''}
{pnlPct.toFixed(1)}%
</p>
<div className="min-w-0">
<Link
href={`/hashtag/${pos.hashtag.tag}`}
className="font-medium hover:text-indigo-300 truncate block"
>
#{pos.hashtag.displayTag}
</Link>
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
pos.positionType === 'LONG'
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{pos.positionType}
</span>
</div>
</div>
)
})}
<span className="text-right text-sm">{formatNumber(pos.shares)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(pos.costBasis)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.currentValue)}</span>
<div className="text-right">
<p className={`text-sm font-medium ${pnlColor(pos.pnl)}`}>{formatPnl(pos.pnl)}</p>
<p className={`text-xs ${pnlColor(pos.pnlPct)}`}>
{pos.pnlPct >= 0 ? '+' : ''}
{pos.pnlPct.toFixed(1)}%
</p>
</div>
</div>
))}
</div>
</div>
)}