From af5484f0cd0fa8ecf6e62bf777c597b5dd32841d Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Wed, 18 Mar 2026 18:51:42 -0400 Subject: [PATCH] feat: add hashtag active duration and extend active window on sell --- .env.example | 2 + prisma/schema.prisma | 4 +- src/app/api/research/route.ts | 3 + src/app/api/trade/route.ts | 15 ++ src/app/stocks/page.tsx | 272 ++++++++++++++++++++++++++++++++++ src/components/Navbar.tsx | 10 +- src/worker/index.ts | 27 +++- 7 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 src/app/stocks/page.tsx diff --git a/.env.example b/.env.example index 6e22182..917e93a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1c777fe..7d87c98 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/api/research/route.ts b/src/app/api/research/route.ts index 708cfc4..967898a 100644 --- a/src/app/api/research/route.ts +++ b/src/app/api/research/route.ts @@ -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 }, diff --git a/src/app/api/trade/route.ts b/src/app/api/trade/route.ts index c92b86f..260b0c7 100644 --- a/src/app/api/trade/route.ts +++ b/src/app/api/trade/route.ts @@ -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 }) } diff --git a/src/app/stocks/page.tsx b/src/app/stocks/page.tsx new file mode 100644 index 0000000..245aa7e --- /dev/null +++ b/src/app/stocks/page.tsx @@ -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 ( + + {label} + + + ) +} + +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 ( +
+ {/* Header */} +
+
+ +

Markets

+ ({total} active) +
+
+ + {/* Table */} +
+ {/* Column headers */} +
+ +
+ +
+
+ +
+
Posts/hr
+
+ +
+
+ + {stocks.length === 0 ? ( +
No active hashtags yet.
+ ) : ( +
+ {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 ( +
+ {/* Rank + hashtag name */} +
+ + {(page - 1) * PAGE_SIZE + i + 1} + + + #{stock.displayTag} + +
+ + {/* Price */} +
+ {formatCurrency(stock.currentPrice)} +
+ + {/* Change */} +
+ {change == null ? ( + + ) : ( +
+

+ {up ? '+' : ''}{formatCurrency(change)} +

+

+ {up ? '+' : ''}{changePct!.toFixed(2)}% +

+
+ )} +
+ + {/* Posts/hr */} +
+ {stock.postsPerHour != null ? stock.postsPerHour.toFixed(1) : '—'} +
+ + {/* Last updated */} +
+ {formatDistanceToNow(stock.lastUpdated, { addSuffix: true })} +
+
+ ) + })} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ {page > 1 && ( + + ← Prev + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next → + + )} +
+ )} +
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9e86a51..be3c072 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -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() { HashEx + {/* Markets link */} + + Markets + + {/* Search */}
diff --git a/src/worker/index.ts b/src/worker/index.ts index 9d465e5..56cbf8e 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -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 }