feat: implement sortable positions table with dynamic sorting and display enhancements
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
This commit is contained in:
+121
-64
@@ -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&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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user