feat: add hashtag active duration and extend active window on sell
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s

This commit is contained in:
2026-03-18 18:51:42 -04:00
parent 561b4d2faf
commit af5484f0cd
7 changed files with 325 additions and 8 deletions
+2
View File
@@ -17,6 +17,8 @@ MASTODON_ACCESS_TOKEN="your-mastodon-access-token"
WORKER_RATE_LIMIT_MS=2000
# How often (minutes) to queue a full price-update sweep (default: 60)
PRICE_UPDATE_INTERVAL_MINUTES=60
# How long (hours) a hashtag stays active after being researched or after its last position closes (default: 24)
HASHTAG_ACTIVE_HOURS=24
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
MAX_PAGES_PER_HASHTAG=5
+3 -1
View File
@@ -43,8 +43,10 @@ model Hashtag {
currentPrice Float @default(0.25)
isActive Boolean @default(true)
isBanned Boolean @default(false)
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
// Consecutive zero-result count (informational)
zeroCount Int @default(0)
// Earliest time this hashtag can be deactivated (set on research + when last position closes)
activeUntil DateTime?
lastUpdated DateTime @default(now())
createdAt DateTime @default(now())
+3
View File
@@ -72,6 +72,7 @@ export async function POST(req: NextRequest) {
}
const price = calcPrice(postsPerHour)
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
// Upsert the hashtag and deduct point atomically
const [hashtag] = await prisma.$transaction([
@@ -82,6 +83,7 @@ export async function POST(req: NextRequest) {
displayTag: raw.trim().replace(/^#+/, ''),
currentPrice: price,
isActive: true,
activeUntil,
priceHistory: {
create: { price, postsPerHour },
},
@@ -90,6 +92,7 @@ export async function POST(req: NextRequest) {
isActive: true,
currentPrice: price,
zeroCount: 0,
activeUntil,
lastUpdated: new Date(),
priceHistory: {
create: { price, postsPerHour },
+15
View File
@@ -116,5 +116,20 @@ export async function POST(req: NextRequest) {
})
})
// When a sell closes the last position on this hashtag globally, extend the active window
if (type === 'SELL_LONG' || type === 'SELL_SHORT') {
const newShares = (existingPosition?.shares ?? 0) - shares
if (newShares <= 0) {
const remaining = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
if (remaining === 0) {
const hours = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
await prisma.hashtag.update({
where: { id: hashtagId },
data: { activeUntil: new Date(Date.now() + hours * 60 * 60 * 1000) },
})
}
}
}
return NextResponse.json({ ok: true })
}
+272
View File
@@ -0,0 +1,272 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils'
import Link from 'next/link'
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2 } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
type SortField = 'price' | 'tag' | 'change' | 'updated'
type SortDir = 'asc' | 'desc'
interface PageProps {
searchParams: { page?: string; sort?: string; dir?: string }
}
function SortLink({
field,
label,
currentSort,
currentDir,
page,
}: {
field: SortField
label: string
currentSort: SortField
currentDir: SortDir
page: number
}) {
const isActive = currentSort === field
const nextDir: SortDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
const Icon = isActive ? (currentDir === 'desc' ? ArrowDown : ArrowUp) : ArrowUpDown
return (
<Link
href={`/stocks?page=1&sort=${field}&dir=${nextDir}`}
className={`flex items-center gap-1 hover:text-slate-200 transition-colors select-none ${isActive ? 'text-indigo-400' : 'text-slate-400'}`}
>
{label}
<Icon className="h-3 w-3" />
</Link>
)
}
export default async function StocksPage({ searchParams }: PageProps) {
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
const sort = (['price', 'tag', 'change', 'updated'].includes(searchParams.sort ?? '')
? searchParams.sort
: 'price') as SortField
const dir = (searchParams.dir === 'asc' ? 'asc' : 'desc') as SortDir
// For change sort: fetch all active hashtags with last 2 price points and sort in-memory
// For other sorts: use DB-native orderBy + pagination
let stocks: {
id: string
tag: string
displayTag: string
currentPrice: number
lastUpdated: Date
previousPrice: number | null
postsPerHour: number | null
holderCount: number
}[]
let total: number
if (sort === 'change') {
// Fetch all active hashtags for in-memory sort
const all = await prisma.hashtag.findMany({
where: { isActive: true },
select: {
id: true,
tag: true,
displayTag: true,
currentPrice: true,
lastUpdated: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: true } },
},
})
const computed = all.map((h) => ({
id: h.id,
tag: h.tag,
displayTag: h.displayTag,
currentPrice: h.currentPrice,
lastUpdated: h.lastUpdated,
previousPrice: h.priceHistory[1]?.price ?? null,
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
holderCount: h._count.positions,
changePct:
h.priceHistory[1]?.price != null
? ((h.currentPrice - h.priceHistory[1].price) / h.priceHistory[1].price) * 100
: 0,
}))
computed.sort((a, b) =>
dir === 'desc' ? b.changePct - a.changePct : a.changePct - b.changePct,
)
total = computed.length
stocks = computed.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
} else {
const orderBy =
sort === 'price'
? { currentPrice: dir }
: sort === 'tag'
? { tag: dir }
: { lastUpdated: dir }
;[total, stocks] = await Promise.all([
prisma.hashtag.count({ where: { isActive: true } }),
prisma.hashtag
.findMany({
where: { isActive: true },
orderBy,
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
select: {
id: true,
tag: true,
displayTag: true,
currentPrice: true,
lastUpdated: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: true } },
},
})
.then((rows) =>
rows.map((h) => ({
id: h.id,
tag: h.tag,
displayTag: h.displayTag,
currentPrice: h.currentPrice,
lastUpdated: h.lastUpdated,
previousPrice: h.priceHistory[1]?.price ?? null,
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
holderCount: h._count.positions,
})),
),
])
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BarChart2 className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Markets</h1>
<span className="text-slate-500 text-sm">({total} active)</span>
</div>
</div>
{/* Table */}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Column headers */}
<div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium">
<SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} />
<div className="text-right">
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} />
</div>
<div className="text-right">
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} />
</div>
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
<div className="text-right">
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} />
</div>
</div>
{stocks.length === 0 ? (
<div className="text-center py-16 text-slate-500">No active hashtags yet.</div>
) : (
<div className="divide-y divide-surface-border">
{stocks.map((stock, i) => {
const prev = stock.previousPrice
const change = prev != null ? stock.currentPrice - prev : null
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
const up = change == null ? null : change >= 0
return (
<div
key={stock.id}
className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
>
{/* Rank + hashtag name */}
<div className="flex items-center gap-3 min-w-0">
<span className="text-slate-600 text-xs w-6 shrink-0 text-right">
{(page - 1) * PAGE_SIZE + i + 1}
</span>
<Link
href={`/hashtag/${stock.tag}`}
className="font-medium hover:text-indigo-300 transition-colors truncate"
>
#{stock.displayTag}
</Link>
</div>
{/* Price */}
<div className="text-right font-medium tabular-nums">
{formatCurrency(stock.currentPrice)}
</div>
{/* Change */}
<div className="text-right tabular-nums">
{change == null ? (
<span className="text-slate-600 text-xs"></span>
) : (
<div>
<p className={`text-sm ${up ? 'text-emerald-400' : 'text-red-400'}`}>
{up ? '+' : ''}{formatCurrency(change)}
</p>
<p className={`text-xs ${up ? 'text-emerald-400' : 'text-red-400'}`}>
{up ? '+' : ''}{changePct!.toFixed(2)}%
</p>
</div>
)}
</div>
{/* Posts/hr */}
<div className="text-right hidden sm:block text-slate-400 text-sm tabular-nums">
{stock.postsPerHour != null ? stock.postsPerHour.toFixed(1) : '—'}
</div>
{/* Last updated */}
<div className="text-right text-xs text-slate-500">
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
{page > 1 && (
<Link
href={`/stocks?page=${page - 1}&sort=${sort}&dir=${dir}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Prev
</Link>
)}
<span className="text-slate-500 text-sm">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link
href={`/stocks?page=${page + 1}&sort=${sort}&dir=${dir}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Next
</Link>
)}
</div>
)}
</div>
)
}
+9 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy, BarChart2 } from 'lucide-react'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
@@ -61,6 +61,14 @@ export function Navbar() {
<span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link>
{/* Markets link */}
<Link
href="/stocks"
className="text-slate-400 hover:text-slate-200 transition-colors text-sm hidden sm:block shrink-0"
>
Markets
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
+21 -6
View File
@@ -41,6 +41,11 @@ const prisma = new PrismaClient({
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
function activeUntilFromNow(): Date {
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
}
// ── Queues (worker side) ──────────────────────────────────────────────────────
@@ -73,13 +78,13 @@ const priceWorker = new Worker(
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
if (!hashtag) return
const now = new Date()
const ttlExpired = !hashtag.activeUntil || hashtag.activeUntil <= now
const ownerCount = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
if (postsPerHour === 0) {
const newZeroCount = hashtag.zeroCount + 1
// Auto-deactivate after 3 consecutive zero-result updates with no owners
const ownerCount = await prisma.position.count({
where: { hashtagId, shares: { gt: 0 } },
})
const shouldDeactivate = newZeroCount >= 3 && ownerCount === 0
const shouldDeactivate = ttlExpired && ownerCount === 0
await prisma.hashtag.update({
where: { id: hashtagId },
@@ -89,7 +94,17 @@ const priceWorker = new Worker(
lastUpdated: new Date(),
},
})
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated' : ''}`)
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`)
return
}
// If TTL expired and no holders, deactivate instead of updating
if (ttlExpired && ownerCount === 0) {
await prisma.hashtag.update({
where: { id: hashtagId },
data: { isActive: false, lastUpdated: new Date() },
})
console.log(`[price] #${tag} deactivated — TTL expired, no holders`)
return
}