From 82b1953c8be035b73f15bf2529456f9919c4f418 Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Thu, 19 Mar 2026 21:04:13 -0400 Subject: [PATCH] feat: implement sortable positions table with dynamic sorting and display enhancements --- src/app/positions/page.tsx | 185 ++++++++++++++++++++++++------------- 1 file changed, 121 insertions(+), 64 deletions(-) diff --git a/src/app/positions/page.tsx b/src/app/positions/page.tsx index 7bc0c13..1d7b4eb 100644 --- a/src/app/positions/page.tsx +++ b/src/app/positions/page.tsx @@ -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 ( + + {label} + + + ) +} + +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(['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 (
@@ -78,72 +146,61 @@ export default async function PositionsPage() { ) : (
{/* Header row */} -
- Hashtag - Shares - Avg buy - Current - Cost basis - Value - P&L +
+ + + + + + +
- {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 ( -
- {/* Hashtag + type badge (+ sparkline on desktop) */} -
-
- -
-
- - #{pos.hashtag.displayTag} - - - {pos.positionType} - -
+ {positions.map((pos) => ( +
+ {/* Hashtag + type badge (+ sparkline on desktop) */} +
+
+
- - {formatNumber(pos.shares)} - {formatCurrency(pos.avgBuyPrice)} - {formatCurrency(pos.hashtag.currentPrice)} - {formatCurrency(costBasis)} - {formatCurrency(currentValue)} - -
-

{formatPnl(pnl)}

-

- {pnlPct >= 0 ? '+' : ''} - {pnlPct.toFixed(1)}% -

+
+ + #{pos.hashtag.displayTag} + + + {pos.positionType} +
- ) - })} + + {formatNumber(pos.shares)} + {formatCurrency(pos.avgBuyPrice)} + {formatCurrency(pos.hashtag.currentPrice)} + {formatCurrency(pos.costBasis)} + {formatCurrency(pos.currentValue)} + +
+

{formatPnl(pos.pnl)}

+

+ {pos.pnlPct >= 0 ? '+' : ''} + {pos.pnlPct.toFixed(1)}% +

+
+
+ ))}
)}