fix tag case, holdings summary, autocomplete, dedicated page for positions
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s

This commit is contained in:
2026-03-18 18:40:14 -04:00
parent b763e011e9
commit 561b4d2faf
8 changed files with 313 additions and 7 deletions
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim().replace(/^#/, '').toLowerCase()
if (!q || q.length < 1) return NextResponse.json([])
const results = await prisma.hashtag.findMany({
where: {
isActive: true,
isBanned: false,
tag: { startsWith: q },
},
orderBy: { currentPrice: 'desc' },
take: 8,
select: { tag: true, displayTag: true, currentPrice: true },
})
return NextResponse.json(results)
}
+60 -2
View File
@@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
import { HashtagCard } from '@/components/HashtagCard' import { HashtagCard } from '@/components/HashtagCard'
import { TrendingUp, Users, Hash } from 'lucide-react' import { TrendingUp, Users, Hash } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { formatPnl, pnlColor } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
@@ -37,9 +38,30 @@ async function getStats() {
return { userCount, hashtagCount, tradeCount, topHashtags, recentTrades } return { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }
} }
async function getHoldings(userId: string) {
const positions = await prisma.position.findMany({
where: { userId, shares: { gt: 0 } },
include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true } } },
})
if (positions.length === 0) return null
const withPnl = positions.map((p) => ({
...p,
pnl:
p.positionType === 'LONG'
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares,
}))
const sorted = [...withPnl].sort((a, b) => b.pnl - a.pnl)
return {
biggestGain: sorted[0].pnl > 0 ? sorted[0] : null,
biggestLoss: sorted[sorted.length - 1].pnl < 0 ? sorted[sorted.length - 1] : null,
}
}
export default async function HomePage() { export default async function HomePage() {
const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] = const session = await getServerSession(authOptions)
await Promise.all([getServerSession(authOptions), getStats()]) const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] =
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)])
return ( return (
<div className="space-y-10"> <div className="space-y-10">
@@ -95,6 +117,42 @@ export default async function HomePage() {
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} /> <StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
</div> </div>
{/* Holdings summary — biggest gain + biggest loss for signed-in users */}
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-400" />
Your top positions
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{holdings.biggestGain && (
<Link
href={`/hashtag/${holdings.biggestGain.hashtag.tag}`}
className="bg-surface-card border border-surface-border rounded-xl p-4 hover:border-indigo-500/50 transition-colors"
>
<p className="text-xs text-slate-500 mb-1">Biggest gain</p>
<p className="font-semibold">#{holdings.biggestGain.hashtag.displayTag}</p>
<p className={`text-sm font-medium mt-1 ${pnlColor(holdings.biggestGain.pnl)}`}>
{formatPnl(holdings.biggestGain.pnl)}
</p>
</Link>
)}
{holdings.biggestLoss && (
<Link
href={`/hashtag/${holdings.biggestLoss.hashtag.tag}`}
className="bg-surface-card border border-surface-border rounded-xl p-4 hover:border-indigo-500/50 transition-colors"
>
<p className="text-xs text-slate-500 mb-1">Biggest loss</p>
<p className="font-semibold">#{holdings.biggestLoss.hashtag.displayTag}</p>
<p className={`text-sm font-medium mt-1 ${pnlColor(holdings.biggestLoss.pnl)}`}>
{formatPnl(holdings.biggestLoss.pnl)}
</p>
</Link>
)}
</div>
</section>
)}
{/* Top hashtags */} {/* Top hashtags */}
{topHashtags.length > 0 && ( {topHashtags.length > 0 && (
<section> <section>
+150
View File
@@ -0,0 +1,150 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
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'
export const dynamic = 'force-dynamic'
function Sparkline({ prices }: { prices: number[] }) {
if (prices.length < 2) return <span className="text-slate-600 text-xs"></span>
const min = Math.min(...prices)
const max = Math.max(...prices)
const range = max - min || 1
const w = 80
const h = 28
const pts = prices
.map((p, i) => {
const x = (i / (prices.length - 1)) * w
const y = h - ((p - min) / range) * (h - 4) - 2
return `${x},${y}`
})
.join(' ')
const up = prices[prices.length - 1] >= prices[0]
return (
<svg width={w} height={h} style={{ overflow: 'visible' }}>
<polyline
points={pts}
fill="none"
stroke={up ? '#34d399' : '#f87171'}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default async function PositionsPage() {
const session = await getServerSession(authOptions)
if (!session) redirect('/auth/signin')
const positions = await prisma.position.findMany({
where: { userId: session.user.id, shares: { gt: 0 } },
orderBy: { updatedAt: 'desc' },
include: {
hashtag: {
select: {
tag: true,
displayTag: true,
currentPrice: true,
priceHistory: {
orderBy: { recordedAt: 'asc' },
take: 20,
select: { price: true },
},
},
},
},
})
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Open Positions</h1>
</div>
{positions.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>You have no open positions.</p>
<Link href="/" className="text-indigo-400 hover:text-indigo-300 text-sm mt-2 inline-block">
Browse trending hashtags
</Link>
</div>
) : (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header row */}
<div className="grid 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="text-right">Avg buy</span>
<span className="text-right">Current</span>
<span className="text-right">Cost basis</span>
<span className="text-right">Value</span>
<span className="text-right">P&amp;L</span>
</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_auto_auto_auto_auto] gap-4 items-center px-4 py-3"
>
{/* Hashtag + type + sparkline */}
<div className="flex items-center gap-3 min-w-0">
<Sparkline prices={sparkPrices} />
<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="text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
<span className="text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
<span className="text-right text-sm text-slate-400">{formatCurrency(costBasis)}</span>
<span className="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>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+8
View File
@@ -107,6 +107,14 @@ export default async function ProfilePage({ params }: Props) {
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Coins className="h-5 w-5 text-indigo-400" /> <Coins className="h-5 w-5 text-indigo-400" />
Open positions Open positions
{isOwn && (
<Link
href="/positions"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
)}
</h2> </h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden"> <div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
+53 -2
View File
@@ -3,15 +3,20 @@
import Link from 'next/link' import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState } from 'react' import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { normalizeTag } from '@/lib/utils' import { normalizeTag } from '@/lib/utils'
type Suggestion = { tag: string; displayTag: string; currentPrice: number }
export function Navbar() { export function Navbar() {
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleSearch(e: React.FormEvent) { function handleSearch(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -19,9 +24,33 @@ export function Navbar() {
if (tag) { if (tag) {
router.push(`/hashtag/${tag}`) router.push(`/hashtag/${tag}`)
setQuery('') setQuery('')
setSuggestions([])
setShowSuggestions(false)
} }
} }
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
const normalized = val.replace(/^#/, '').trim().toLowerCase()
if (normalized.length < 1) {
setSuggestions([])
setShowSuggestions(false)
return
}
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/hashtags/search?q=${encodeURIComponent(normalized)}`)
if (res.ok) {
const data: Suggestion[] = await res.json()
setSuggestions(data)
setShowSuggestions(data.length > 0)
}
} catch {}
}, 300)
}
return ( return (
<nav className="border-b border-surface-border bg-surface-card"> <nav className="border-b border-surface-border bg-surface-card">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -39,10 +68,32 @@ export function Navbar() {
<input <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder="#hashtag" placeholder="#hashtag"
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/> />
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface-card border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
{suggestions.map((s) => (
<button
key={s.tag}
type="button"
onMouseDown={() => {
router.push(`/hashtag/${s.tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
>
<span className="font-medium">#{s.displayTag}</span>
<span className="text-slate-400 text-xs">{formatCurrency(s.currentPrice)}</span>
</button>
))}
</div>
)}
</div> </div>
</form> </form>
+18 -2
View File
@@ -60,7 +60,7 @@ export async function getPostsPerHour(tag: string): Promise<number> {
*/ */
export async function getPostsData( export async function getPostsData(
tag: string, tag: string,
): Promise<{ postsPerHour: number; relatedTags: string[] }> { ): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string }> {
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10) const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
let allPosts: MastodonPost[] = [] let allPosts: MastodonPost[] = []
@@ -110,5 +110,21 @@ export async function getPostsData(
.slice(0, 10) .slice(0, 10)
.map(([name]) => name) .map(([name]) => name)
return { postsPerHour, relatedTags } // Derive the most common casing variant for the queried tag itself
const casingCounts = new Map<string, number>()
for (const post of allPosts) {
for (const t of post.tags ?? []) {
if (t.name.toLowerCase() === lowerTag) {
casingCounts.set(t.name, (casingCounts.get(t.name) ?? 0) + 1)
}
}
}
let displayTag: string | undefined
if (casingCounts.size > 0) {
const total = [...casingCounts.values()].reduce((a, b) => a + b, 0)
const [topVariant, topCount] = [...casingCounts.entries()].sort((a, b) => b[1] - a[1])[0]
if (topCount / total >= 0.5) displayTag = topVariant
}
return { postsPerHour, relatedTags, displayTag }
} }
+1
View File
@@ -3,6 +3,7 @@ export { default } from 'next-auth/middleware'
export const config = { export const config = {
matcher: [ matcher: [
'/profile/:path*', '/profile/:path*',
'/positions',
'/admin/:path*', '/admin/:path*',
'/api/trade/:path*', '/api/trade/:path*',
'/api/research/:path*', '/api/research/:path*',
+3 -1
View File
@@ -62,8 +62,9 @@ const priceWorker = new Worker(
let postsPerHour = 0 let postsPerHour = 0
let relatedTags: string[] = [] let relatedTags: string[] = []
let displayTag: string | undefined
try { try {
;({ postsPerHour, relatedTags } = await getPostsData(tag)) ;({ postsPerHour, relatedTags, displayTag } = await getPostsData(tag))
} catch (err) { } catch (err) {
console.error(`[price] mastodon error for #${tag}:`, err) console.error(`[price] mastodon error for #${tag}:`, err)
throw err // BullMQ will retry throw err // BullMQ will retry
@@ -101,6 +102,7 @@ const priceWorker = new Worker(
currentPrice: newPrice, currentPrice: newPrice,
zeroCount: 0, zeroCount: 0,
lastUpdated: new Date(), lastUpdated: new Date(),
...(displayTag && displayTag !== hashtag.displayTag ? { displayTag } : {}),
}, },
}), }),
prisma.priceHistory.create({ prisma.priceHistory.create({