fix tag case, holdings summary, autocomplete, dedicated page for positions
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
This commit is contained in:
@@ -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
@@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth'
|
||||
import { HashtagCard } from '@/components/HashtagCard'
|
||||
import { TrendingUp, Users, Hash } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { formatPnl, pnlColor } from '@/lib/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
@@ -37,9 +38,30 @@ async function getStats() {
|
||||
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() {
|
||||
const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] =
|
||||
await Promise.all([getServerSession(authOptions), getStats()])
|
||||
const session = await getServerSession(authOptions)
|
||||
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] =
|
||||
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)])
|
||||
|
||||
return (
|
||||
<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()} />
|
||||
</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 */}
|
||||
{topHashtags.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -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&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>
|
||||
)
|
||||
}
|
||||
@@ -107,6 +107,14 @@ export default async function ProfilePage({ params }: Props) {
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Coins className="h-5 w-5 text-indigo-400" />
|
||||
Open positions
|
||||
{isOwn && (
|
||||
<Link
|
||||
href="/positions"
|
||||
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
)}
|
||||
</h2>
|
||||
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-surface-border">
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
import Link from 'next/link'
|
||||
import { useSession, signOut } from 'next-auth/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 { formatCurrency } from '@/lib/utils'
|
||||
import { normalizeTag } from '@/lib/utils'
|
||||
|
||||
type Suggestion = { tag: string; displayTag: string; currentPrice: number }
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session } = useSession()
|
||||
const router = useRouter()
|
||||
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) {
|
||||
e.preventDefault()
|
||||
@@ -19,9 +24,33 @@ export function Navbar() {
|
||||
if (tag) {
|
||||
router.push(`/hashtag/${tag}`)
|
||||
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 (
|
||||
<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">
|
||||
@@ -39,10 +68,32 @@ export function Navbar() {
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={handleQueryChange}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
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"
|
||||
/>
|
||||
{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>
|
||||
</form>
|
||||
|
||||
|
||||
+18
-2
@@ -60,7 +60,7 @@ export async function getPostsPerHour(tag: string): Promise<number> {
|
||||
*/
|
||||
export async function getPostsData(
|
||||
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)
|
||||
|
||||
let allPosts: MastodonPost[] = []
|
||||
@@ -110,5 +110,21 @@ export async function getPostsData(
|
||||
.slice(0, 10)
|
||||
.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 }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export { default } from 'next-auth/middleware'
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/profile/:path*',
|
||||
'/positions',
|
||||
'/admin/:path*',
|
||||
'/api/trade/:path*',
|
||||
'/api/research/:path*',
|
||||
|
||||
+3
-1
@@ -62,8 +62,9 @@ const priceWorker = new Worker(
|
||||
|
||||
let postsPerHour = 0
|
||||
let relatedTags: string[] = []
|
||||
let displayTag: string | undefined
|
||||
try {
|
||||
;({ postsPerHour, relatedTags } = await getPostsData(tag))
|
||||
;({ postsPerHour, relatedTags, displayTag } = await getPostsData(tag))
|
||||
} catch (err) {
|
||||
console.error(`[price] mastodon error for #${tag}:`, err)
|
||||
throw err // BullMQ will retry
|
||||
@@ -101,6 +102,7 @@ const priceWorker = new Worker(
|
||||
currentPrice: newPrice,
|
||||
zeroCount: 0,
|
||||
lastUpdated: new Date(),
|
||||
...(displayTag && displayTag !== hashtag.displayTag ? { displayTag } : {}),
|
||||
},
|
||||
}),
|
||||
prisma.priceHistory.create({
|
||||
|
||||
Reference in New Issue
Block a user