Compare commits

..

31 Commits

Author SHA1 Message Date
ThaMunsta d4acc1d61c fix profile link preview
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-05-04 02:11:22 -04:00
ThaMunsta b25f300edf Format display tag and username with prefix symbols for consistency in Open Graph API responses
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:49:40 -04:00
ThaMunsta 1bed1f2040 Add metadataBase to Open Graph API for dynamic URL handling
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-05-04 01:20:57 -04:00
ThaMunsta 54839e6034 Set dynamic rendering for leaderboard Open Graph API
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:12:15 -04:00
ThaMunsta d51e0d6507 Refactor host URL retrieval in Open Graph API routes for improved error handling
Build Images and Deploy / Update-PROD-Stack (push) Failing after 36s
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:10:18 -04:00
ThaMunsta 3f542289a8 Add Open Graph metadata generation for fund, leaderboard, and profile pages
Build Images and Deploy / Update-PROD-Stack (push) Failing after 36s
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:03:39 -04:00
ThaMunsta 1dcabdf6db Add metadata generation for hashtag pages and create dynamic image response
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m38s
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 00:52:45 -04:00
ThaMunsta a03ab09d05 Filter positions to only include those with shares greater than zero in hashtag query
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-04-29 22:45:51 -04:00
ThaMunsta ad15792621 Refine hashtag and stock query counts to only include positions with shares greater than zero
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m48s
2026-04-29 21:32:51 -04:00
ThaMunsta e6e1b895e7 spins to wins
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m32s
2026-04-01 16:04:47 -04:00
ThaMunsta 9e93c3db57 Update time references to Eastern Time and adjust maintenance job schedule
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m28s
2026-03-27 22:01:53 -04:00
ThaMunsta f4c379b0d4 more grainular pph math
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-24 01:57:04 -04:00
ThaMunsta 15378c1eec Switch to a Michaelis-Menten saturating curve. search bar filtering. mobile improvements
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m50s
2026-03-24 01:44:11 -04:00
ThaMunsta 100f149c53 easier fund management and navigations
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
2026-03-22 01:20:34 -04:00
ThaMunsta d68bc99817 add related hashtag pruning to maintenance worker
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-22 00:55:21 -04:00
ThaMunsta 72885ed0b0 add research points to new hedge funds and update maintenance job schedule
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-22 00:31:52 -04:00
ThaMunsta 3ce7bd36b8 this either fixes price charts or makes them backwards. lets see
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-21 23:45:49 -04:00
ThaMunsta 8fd5484e86 remove db limit so grpah displays all history
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-21 23:15:07 -04:00
ThaMunsta 34ecec2da6 try to fix active stocks losing price updates
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-21 21:47:47 -04:00
ThaMunsta e2dc3ea492 new post fetch strategy
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m42s
2026-03-21 18:00:02 -04:00
ThaMunsta efdef6149a fix ugly bubbles
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-21 14:46:55 -04:00
ThaMunsta 02119e3b56 dont need to fetch if its older than 24 hours thats crazy
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
2026-03-21 14:43:15 -04:00
ThaMunsta e067d3f5c7 redefine logic to try and get a stable price with unstable timeline
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-21 14:19:11 -04:00
ThaMunsta 997f1041f0 time formats
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-21 13:05:07 -04:00
ThaMunsta 2eb3ebad48 better logging on price updates
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-21 12:57:43 -04:00
ThaMunsta a280891359 fix build err
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-21 01:51:51 -04:00
ThaMunsta 468b8b6677 feat: add managed funds section to user profile and enhance trade display for fund-related transactions
Build Images and Deploy / Update-PROD-Stack (push) Failing after 34s
2026-03-21 01:48:45 -04:00
ThaMunsta 9c3312ed75 fix: correct balance update logic for user and fund in investment and redemption processes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-21 01:33:04 -04:00
ThaMunsta 74a204ea39 feat: enhance About page with Lucky Dip link and improve admin fund applications UI
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-21 01:14:26 -04:00
ThaMunsta 05a9d8f7af add more P&L visuals
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m25s
2026-03-20 23:59:44 -04:00
ThaMunsta 81f7d90be1 feat: enhance fund application process by displaying managed funds for applicants
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-20 23:47:09 -04:00
32 changed files with 1183 additions and 384 deletions
+15 -10
View File
@@ -152,27 +152,32 @@ All variables are documented in `.env.example`. Key ones:
## Pricing Formula
Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices:
```
price = max($0.25, round(postsPerHour × $0.25, 2))
price = max($0.25, round((base × pph) / (1 + k × pph), 2))
```
Examples:
`k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second).
| Posts/hr | Price |
|---|---|
| 1 | $0.25 |
| 10 | $2.50 |
| 100 | $25.00 |
| 1,000 | $250.00 |
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 |
| 1 | ~$0.25 |
| 10 | ~$2.48 |
| 100 | ~$23.32 |
| 1,000 | ~$145 |
| 3,600 (one post/sec) | ~$250 |
| ∞ (theoretical) | ~$346 (asymptote) |
**Burst handling:** when all fetched posts share a very tight timestamp window the worker paginates up to `MAX_PAGES_PER_HASHTAG` pages to get a realistic count before the span grows to > 5 minutes.
At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market.
**Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window.
---
## Research System
- Every player earns **1 research point per day** (awarded at 00:05 UTC by the maintenance worker).
- Every player earns **1 research point per day** (awarded at midnight EST by the maintenance worker).
- Balance milestones unlock extra daily points:
| Balance | Daily points |
@@ -217,7 +222,7 @@ Three BullMQ queues:
|---|---|
| `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
| `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. |
| `hashex-maintenance` | Runs daily at 00:05 UTC. Awards research points based on each player's balance. |
| `hashex-maintenance` | Runs daily at midnight EST. Awards research points based on each player's balance. |
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
+2
View File
@@ -20,6 +20,7 @@ services:
MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}"
FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}"
FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}"
TZ: "America/Toronto"
depends_on:
postgres:
condition: service_healthy
@@ -43,6 +44,7 @@ services:
MASTODON_POST_LIMIT: "${MASTODON_POST_LIMIT:-20}"
PRICE_HISTORY_ACTIVE_DAYS: "${PRICE_HISTORY_ACTIVE_DAYS:-7}"
PRICE_HISTORY_INACTIVE_HOURS: "${PRICE_HISTORY_INACTIVE_HOURS:-24}"
TZ: "America/Toronto"
depends_on:
postgres:
condition: service_healthy
+17 -5
View File
@@ -71,12 +71,22 @@ export default function AboutPage() {
<Section title="How Prices Work" icon={Coins}>
<div className="space-y-2 text-sm text-slate-300">
<p>
Every hashtag has a price calculated from its post rate on Mastodon:
Every hashtag has a price derived from its posts-per-hour rate on Mastodon using a
{' '}<span className="text-white font-medium">saturating curve</span> prices rise quickly at
low activity and flatten at high activity so a single viral tag can&apos;t dominate the market.
</p>
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300">
price = max($0.25, posts_per_hour × $0.25)
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300 text-xs">
price = (0.25 × pph) / (1 + k × pph) &nbsp;·&nbsp; floor $0.25
</div>
<p>
<div className="grid grid-cols-2 gap-x-4 text-xs mt-1">
<span className="text-slate-400">1 post / hr</span><span>~$0.25</span>
<span className="text-slate-400">10 posts / hr</span><span>~$2.48</span>
<span className="text-slate-400">100 posts / hr</span><span>~$23</span>
<span className="text-slate-400">1,000 posts / hr</span><span>~$145</span>
<span className="text-slate-400">3,600 posts / hr (1/sec)</span><span>~$250</span>
<span className="text-slate-400"> (asymptote)</span><span>~$346</span>
</div>
<p className="text-xs text-slate-400">
Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be
automatically <span className="text-orange-400">deactivated</span> you&apos;ll get a warning on the
home page if any of your positions are at risk. Research it again to reactivate it.
@@ -181,7 +191,9 @@ export default function AboutPage() {
<Section title="Lucky Dip" icon={Shuffle}>
<p className="text-sm text-slate-300">
Once per day you can open the Lucky Dip lottery. Pick a box most are empty, but a few hold cash prizes.
Winnings are added directly to your balance.
Winnings are added directly to your balance. So head over and check out our
<Link href="/lucky-dip" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
Lucky Dip page</Link> now or you can always find the link on our home page.
</p>
</Section>
+44 -38
View File
@@ -2,12 +2,13 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { FileText, Check, X, ChevronDown, ChevronUp } from 'lucide-react'
import { FileText, X, ChevronDown, ChevronUp } from 'lucide-react'
interface Applicant {
id: string
username: string
displayUsername: string | null
managedFunds: { fund: { name: string; slug: string } }[]
}
interface Application {
@@ -22,8 +23,7 @@ export default function AdminFundApplications({ applications: initial }: { appli
const router = useRouter()
const [applications, setApplications] = useState<Application[]>(initial)
const [expanded, setExpanded] = useState<string | null>(null)
const [approveId, setApproveId] = useState<string | null>(null)
const [startingBalance, setStartingBalance] = useState('0')
const [startingBalance, setStartingBalance] = useState('10000')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -39,11 +39,12 @@ export default function AdminFundApplications({ applications: initial }: { appli
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setApplications(applications.filter((a) => a.id !== app.id))
setApproveId(null)
setExpanded(null)
router.refresh()
}
async function deny(id: string) {
async function deny(id: string, e: React.MouseEvent) {
e.stopPropagation()
if (!confirm('Deny this application? The applicant will not be notified.')) return
setLoading(true)
const res = await fetch(`/api/admin/fund-applications/${id}`, {
@@ -69,9 +70,15 @@ export default function AdminFundApplications({ applications: initial }: { appli
{error && (
<p className="text-red-400 text-sm bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">{error}</p>
)}
{applications.map((app) => (
{applications.map((app) => {
const isOpen = expanded === app.id
return (
<div key={app.id} className="bg-surface-card border border-amber-500/20 rounded-xl overflow-hidden">
<div className="flex items-start justify-between px-4 py-3">
{/* Clickable header row */}
<div
className="flex items-start justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors"
onClick={() => setExpanded(isOpen ? null : app.id)}
>
<div className="flex items-start gap-3 min-w-0">
<FileText className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
<div className="min-w-0">
@@ -83,45 +90,47 @@ export default function AdminFundApplications({ applications: initial }: { appli
</p>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<button
onClick={() => setExpanded(expanded === app.id ? null : app.id)}
className="text-slate-400 hover:text-slate-200 transition-colors"
aria-label="Toggle reason"
>
{expanded === app.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
<button
onClick={() => deny(app.id)}
onClick={(e) => deny(app.id, e)}
disabled={loading}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-lg transition-colors disabled:opacity-50"
title="Deny application"
>
<X className="h-4 w-4" />
</button>
<button
onClick={() => setApproveId(approveId === app.id ? null : app.id)}
disabled={loading}
className="p-1.5 text-green-400 hover:text-green-300 hover:bg-green-400/10 rounded-lg transition-colors disabled:opacity-50"
title="Approve application"
>
<Check className="h-4 w-4" />
</button>
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
</div>
</div>
{expanded === app.id && (
<div className="px-4 pb-3 text-sm text-slate-300 whitespace-pre-wrap border-t border-surface-border pt-3">
{app.reason}
</div>
)}
{/* Expanded: reason + approve form */}
{isOpen && (
<div className="border-t border-surface-border px-4 pt-3 pb-4 space-y-4">
<p className="text-sm text-slate-300 whitespace-pre-wrap">{app.reason}</p>
{approveId === app.id && (
<div className="px-4 pb-4 border-t border-surface-border pt-3 space-y-3">
<div className="space-y-2">
<p className="text-xs text-slate-400">
Approve <span className="text-white font-medium">{app.fundName}</span> for{' '}
<span className="text-slate-200">{app.user.displayUsername ?? app.user.username}</span>
{app.user.managedFunds.length > 0 && (
<span className="text-slate-500">
{' '}(also manages{' '}
{app.user.managedFunds.map((m, i) => (
<span key={m.fund.slug}>
{i > 0 && ', '}
<a
href={`/fund/${m.fund.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2"
>
{m.fund.name}
</a>
</span>
))}
)
</span>
)}
</p>
<div className="flex items-center gap-3">
<div>
@@ -130,6 +139,7 @@ export default function AdminFundApplications({ applications: initial }: { appli
type="number"
min="0"
value={startingBalance}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setStartingBalance(e.target.value)}
className="w-32 bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
@@ -142,18 +152,14 @@ export default function AdminFundApplications({ applications: initial }: { appli
>
Confirm Approval
</button>
<button
onClick={() => setApproveId(null)}
className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-100"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
))}
)
})}
</div>
)
}
+10 -1
View File
@@ -18,7 +18,16 @@ export default async function AdminFundsPage() {
}),
prisma.fundApplication.findMany({
orderBy: { createdAt: 'asc' },
include: { user: { select: { id: true, username: true, displayUsername: true } } },
include: {
user: {
select: {
id: true,
username: true,
displayUsername: true,
managedFunds: { select: { fund: { select: { name: true, slug: true } } } },
},
},
},
}),
])
+29 -12
View File
@@ -67,26 +67,43 @@ export default async function AdminOverviewPage() {
Recent trades
</h2>
<div className="divide-y divide-surface-border">
{recentTrades.map((t) => (
{recentTrades.map((t) => {
const isLottery = t.type === 'LOTTERY_WIN'
const isBuy = t.type.startsWith('BUY')
const isSell = t.type === 'SELL_LONG' || t.type === 'SELL_SHORT'
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
const isSystem = t.type === 'ACCOUNT_OPEN' || t.type === 'DONATION' || t.type === 'BANKRUPTCY'
const badgeClass = isLottery
? 'bg-amber-500/15 text-amber-400'
: isFundTrade
? 'bg-indigo-500/15 text-indigo-400'
: isSystem
? 'bg-slate-500/15 text-slate-400'
: isBuy
? 'bg-emerald-500/15 text-emerald-400'
: isSell
? 'bg-red-500/15 text-red-400'
: 'bg-slate-500/15 text-slate-400'
const label = t.hashtag
? `#${t.hashtag.displayTag}`
: isLottery
? 'Lucky Dip'
: isFundTrade
? 'Fund'
: null
return (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span
className={`text-xs px-1.5 py-0.5 rounded ${
t.type === 'LOTTERY_WIN'
? 'bg-amber-500/15 text-amber-400'
: t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}
>
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
{t.type.replace(/_/g, ' ')}
</span>
<span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500">
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
</span>
{label && <span className="text-slate-500">{label}</span>}
</div>
<span>{formatCurrency(t.total)}</span>
</div>
))}
)
})}
</div>
</div>
</div>
+5 -2
View File
@@ -29,7 +29,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
skip,
take: pageSize,
include: {
_count: { select: { positions: true, trades: true } },
_count: { select: { positions: { where: { shares: { gt: 0 } } }, trades: true } },
},
}),
prisma.hashtag.count({ where }),
@@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) {
{hashtags.map((h) => (
<tr key={h.id} className="hover:bg-surface-hover">
<td className="px-4 py-3">
<a href={`/hashtag/${h.tag}`} className="hover:text-indigo-300">
<a
href={`/hashtag/${h.tag}`}
className={`hover:text-indigo-300 ${!h.isActive && !h.isBanned ? 'text-slate-500' : ''}`}
>
#{h.displayTag}
</a>
</td>
+1
View File
@@ -61,6 +61,7 @@ export async function POST(req: NextRequest) {
displayUsername: name,
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable
balance: initialBalance,
researchPoints: 0,
isFund: true,
},
})
+2 -2
View File
@@ -68,9 +68,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const [updatedInvestor] = await prisma.$transaction([
// Deduct from investor (returns updated user with new balance)
prisma.user.update({ where: { id: session.user.id }, data: { balance: { decrement: amount } } }),
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance - amount) } }),
// Add to fund's cash
prisma.user.update({ where: { id: fund.userId }, data: { balance: { increment: amount } } }),
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance + amount) } }),
// Upsert FundInvestment record
prisma.fundInvestment.upsert({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
+5 -2
View File
@@ -42,6 +42,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
}
const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true } })
if (!investor) return NextResponse.json({ error: 'User not found' }, { status: 404 })
const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
@@ -61,9 +64,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const [updatedInvestor] = await prisma.$transaction([
// Return cash to investor
prisma.user.update({ where: { id: session.user.id }, data: { balance: { increment: payout } } }),
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance + payout) } }),
// Deduct from fund's cash
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance - payout) } }),
// Update or delete FundInvestment
...(remainingShares > 0
? [prisma.fundInvestment.update({
+4 -4
View File
@@ -16,9 +16,9 @@ function buildPrizes(): number[] {
function isSameDay(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
@@ -26,7 +26,7 @@ function isSameDay(a: Date, b: Date) {
* POST /api/lottery/pick
* Body: { box: number } (0-indexed, 024)
*
* One free play per calendar day (UTC). Reveals prize at the chosen box.
* One free play per calendar day (Eastern Time). Reveals prize at the chosen box.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
+99
View File
@@ -0,0 +1,99 @@
import { ImageResponse } from 'next/og'
import { prisma } from '@/lib/prisma'
import { calcFundNav } from '@/lib/pricing'
export const runtime = 'nodejs'
const W = 1200
const H = 630
export async function GET(
_req: Request,
{ params }: { params: { slug: string } },
) {
const slug = decodeURIComponent(params.slug).toLowerCase()
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
select: {
name: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
positionType: true,
shares: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
managers: { select: { userId: true } },
_count: { select: { investments: true } },
},
})
const name = fund?.name ?? slug
const cash = fund?.user.balance ?? 0
const portfolioValue = fund?.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
return sum + val
}, 0) ?? 0
const totalValue = cash + portfolioValue
const nav = fund ? calcFundNav(totalValue, fund.sharesOutstanding) : 1
const managerCount = fund?.managers.length ?? 0
const investorCount = fund?._count.investments ?? 0
const openPositions = fund?.user.positions.length ?? 0
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
}).format(n)
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
return new ImageResponse(
(
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
{/* Branding + badge */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
<div style={{ fontSize: 20, color: '#818cf8', background: '#312e81', borderRadius: 8, padding: '6px 16px' }}>Hedge Fund</div>
</div>
{/* Fund name */}
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>{name}</div>
{/* Stats grid */}
<div style={{ display: 'flex', gap: 28, flex: 1 }}>
{[
{ label: 'Total Value', value: fmt(totalValue), color: '#ffffff' },
{ label: 'NAV / Share', value: fmt(nav), color: '#ffffff' },
{ label: 'Cash', value: fmt(cash), color: '#94a3b8' },
{ label: 'Positions', value: String(openPositions), color: '#94a3b8' },
{ label: 'Managers', value: String(managerCount), color: '#94a3b8' },
{ label: 'Investors', value: String(investorCount), color: '#94a3b8' },
].map(({ label, value, color }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 20px', flex: 1 }}>
<div style={{ fontSize: 16, color: '#475569', marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
</div>
))}
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
<span>{host}</span>
<span>Trade hashtags like stocks</span>
</div>
</div>
),
{ width: W, height: H },
)
}
+154
View File
@@ -0,0 +1,154 @@
import { ImageResponse } from 'next/og'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
const W = 1200
const H = 630
const CHART_X = 60
const CHART_Y = 200
const CHART_W = W - 120
const CHART_H = 220
function buildPolyline(prices: number[]): string {
if (prices.length < 2) return ''
const min = Math.min(...prices)
const max = Math.max(...prices)
const range = max - min || 1
return prices
.map((p, i) => {
const x = CHART_X + (i / (prices.length - 1)) * CHART_W
const y = CHART_Y + CHART_H - ((p - min) / range) * CHART_H
return `${x},${y}`
})
.join(' ')
}
export async function GET(
_req: Request,
{ params }: { params: { tag: string } },
) {
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const hashtag = await prisma.hashtag.findUnique({
where: { tag },
select: {
displayTag: true,
currentPrice: true,
isActive: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 48,
select: { price: true },
},
},
})
const displayTag = hashtag?.displayTag ?? tag
const price = hashtag?.currentPrice ?? 0.25
const prices = (hashtag?.priceHistory ?? []).map((p) => p.price).reverse()
const prevPrice = prices.length >= 2 ? prices[0] : null
const changePct = prevPrice && prevPrice > 0
? ((price - prevPrice) / prevPrice) * 100
: null
const trending = changePct === null ? null : changePct >= 0
const lineColor = trending === null ? '#6366f1' : trending ? '#34d399' : '#f87171'
const changeStr = changePct === null
? ''
: `${changePct >= 0 ? '+' : ''}${changePct.toFixed(2)}%`
const priceStr = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price)
const polyline = buildPolyline(prices)
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
return new ImageResponse(
(
<div
style={{
width: W,
height: H,
background: '#0f0f17',
display: 'flex',
flexDirection: 'column',
padding: '60px',
fontFamily: 'sans-serif',
position: 'relative',
}}
>
{/* Header row */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 28, color: '#6366f1', marginBottom: 8 }}>HashEx</div>
<div style={{ fontSize: 72, fontWeight: 700, color: '#ffffff' }}>
{'#' + displayTag}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{ fontSize: 64, fontWeight: 700, color: '#ffffff' }}>{priceStr}</div>
{changeStr && (
<div style={{ fontSize: 36, fontWeight: 600, color: lineColor, marginTop: 4 }}>
{changeStr}
</div>
)}
{!hashtag?.isActive && (
<div style={{ fontSize: 22, color: '#f97316', marginTop: 8 }}>inactive</div>
)}
</div>
</div>
{/* Sparkline */}
{prices.length >= 2 && (
<svg
width={W}
height={CHART_H + 40}
style={{ position: 'absolute', left: 0, top: 280 }}
>
{/* Subtle grid line at mid-price */}
<line
x1={CHART_X}
y1={CHART_Y + CHART_H / 2}
x2={CHART_X + CHART_W}
y2={CHART_Y + CHART_H / 2}
stroke="#1e1e2e"
strokeWidth={1}
/>
<polyline
points={polyline}
fill="none"
stroke={lineColor}
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.9}
/>
</svg>
)}
{/* Footer */}
<div
style={{
position: 'absolute',
bottom: 40,
left: 60,
right: 60,
display: 'flex',
justifyContent: 'space-between',
color: '#475569',
fontSize: 22,
}}
>
<span>{host}</span>
<span>Trade hashtags like stocks</span>
</div>
</div>
),
{ width: W, height: H },
)
}
+71
View File
@@ -0,0 +1,71 @@
import { ImageResponse } from 'next/og'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const W = 1200
const H = 630
export async function GET() {
const users = await prisma.user.findMany({
where: { isFund: false, isHidden: false },
select: {
displayUsername: true,
username: true,
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: { shares: true, hashtag: { select: { currentPrice: true } } },
},
},
})
const ranked = users
.map((u) => ({
name: u.displayUsername ?? u.username,
netWorth: u.balance + u.positions.reduce((s, p) => s + p.shares * p.hashtag.currentPrice, 0),
}))
.sort((a, b) => b.netWorth - a.netWorth)
.slice(0, 5)
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD', notation: n >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
}).format(n)
const medals = ['🥇', '🥈', '🥉', '4.', '5.']
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
return new ImageResponse(
(
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 40 }}>
<div style={{ fontSize: 26, color: '#6366f1' }}>HashEx</div>
<div style={{ fontSize: 42, fontWeight: 700, color: '#ffffff' }}>🏆 Leaderboard</div>
</div>
{/* Top 5 rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
{ranked.map((u, i) => (
<div key={u.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: i === 0 ? '#1c1a08' : '#1a1a2e', border: `1px solid ${i === 0 ? '#854d0e' : '#1e2035'}`, borderRadius: 14, padding: '18px 28px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
<span style={{ fontSize: 28, width: 40 }}>{medals[i]}</span>
<span style={{ fontSize: 30, fontWeight: 600, color: i === 0 ? '#fde68a' : '#e2e8f0' }}>{u.name}</span>
</div>
<span style={{ fontSize: 30, fontWeight: 700, color: i === 0 ? '#fde68a' : '#ffffff' }}>{fmt(u.netWorth)}</span>
</div>
))}
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 32 }}>
<span>{host}</span>
<span>Trade hashtags like stocks</span>
</div>
</div>
),
{ width: W, height: H },
)
}
@@ -0,0 +1,88 @@
import { ImageResponse } from 'next/og'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
const W = 1200
const H = 630
export async function GET(
_req: Request,
{ params }: { params: { username: string } },
) {
const username = decodeURIComponent(params.username).toLowerCase()
const user = await prisma.user.findUnique({
where: { username },
select: {
displayUsername: true,
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
positionType: true,
shares: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
_count: { select: { trades: true } },
},
})
const displayName = user?.displayUsername ?? username
const balance = user?.balance ?? 0
const portfolioValue = user?.positions.reduce((sum, p) => sum + p.shares * p.hashtag.currentPrice, 0) ?? 0
const netWorth = balance + portfolioValue
const unrealizedPnl = user?.positions.reduce((sum, p) => {
if (p.positionType === 'LONG') return sum + (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
return sum + (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
}, 0) ?? 0
const tradeCount = user?._count.trades ?? 0
const openPositions = user?.positions.length ?? 0
const fmt = (n: number) => new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'USD', notation: Math.abs(n) >= 10000 ? 'compact' : 'standard', maximumFractionDigits: 2,
}).format(n)
const pnlColor = unrealizedPnl >= 0 ? '#34d399' : '#f87171'
const rawUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const host = (() => { try { return new URL(rawUrl).host } catch { return rawUrl } })()
return new ImageResponse(
(
<div style={{ width: W, height: H, background: '#0f0f17', display: 'flex', flexDirection: 'column', padding: '60px', fontFamily: 'sans-serif' }}>
{/* Branding */}
<div style={{ fontSize: 26, color: '#6366f1', marginBottom: 24 }}>HashEx</div>
{/* Name */}
<div style={{ fontSize: 68, fontWeight: 700, color: '#ffffff', marginBottom: 48 }}>
{'@' + displayName}
</div>
{/* Stats grid */}
<div style={{ display: 'flex', gap: 32, flex: 1 }}>
{[
{ label: 'Net Worth', value: fmt(netWorth), color: '#ffffff' },
{ label: 'Cash', value: fmt(balance), color: '#94a3b8' },
{ label: 'Unrealized P&L', value: fmt(unrealizedPnl), color: pnlColor },
{ label: 'Open Positions', value: String(openPositions), color: '#94a3b8' },
{ label: 'Total Trades', value: String(tradeCount), color: '#94a3b8' },
].map(({ label, value, color }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', background: '#1a1a2e', border: '1px solid #1e2035', borderRadius: 16, padding: '24px 28px', flex: 1 }}>
<div style={{ fontSize: 18, color: '#475569', marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 30, fontWeight: 700, color }}>{value}</div>
</div>
))}
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#475569', fontSize: 22, marginTop: 40 }}>
<span>{host}</span>
<span>Trade hashtags like stocks</span>
</div>
</div>
),
{ width: W, height: H },
)
}
+21 -1
View File
@@ -11,8 +11,28 @@ import { calcFundNav } from '@/lib/pricing'
import InvestPanel from './InvestPanel'
import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const slug = decodeURIComponent(params.slug).toLowerCase()
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
select: { name: true },
})
const name = fund?.name ?? slug
const title = `${name} — HashEx Hedge Fund`
const description = `${name} is a hedge fund on HashEx trading Mastodon hashtags. View their portfolio, NAV, and performance.`
const imageUrl = `/api/og/fund/${encodeURIComponent(slug)}`
return {
title,
description,
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${name} fund overview` }] },
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
}
}
export default async function FundPage({ params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions)
const slug = decodeURIComponent(params.slug).toLowerCase()
@@ -147,7 +167,7 @@ export default async function FundPage({ params }: { params: { slug: string } })
Search for a hashtag below and trade using the fund&apos;s balance.
All positions and profit belong to the fund.
</p>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
{positions.map((p) => (
<Link
key={p.id}
+28 -19
View File
@@ -18,25 +18,6 @@ export default function FundApplicationClient({ existing, managedFund }: Props)
const [loading, setLoading] = useState(false)
const [withdrawing, setWithdrawing] = useState(false)
// Already a fund manager
if (managedFund) {
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-3">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-green-400" />
<p className="font-medium">You already manage a fund</p>
</div>
<p className="text-sm text-slate-400">
You are a manager of{' '}
<Link href={`/fund/${managedFund.slug}`} className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
{managedFund.name}
</Link>
.
</p>
</div>
)
}
// Pending application
if (existing) {
async function withdraw() {
@@ -47,6 +28,19 @@ export default function FundApplicationClient({ existing, managedFund }: Props)
}
return (
<div className="space-y-4">
{managedFund && (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-start gap-3">
<CheckCircle className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
<p className="text-sm text-slate-400">
You already manage{' '}
<Link href={`/fund/${managedFund.slug}`} className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
{managedFund.name}
</Link>
. You can still apply for an additional fund.
</p>
</div>
)}
<div className="bg-surface-card border border-indigo-500/30 rounded-xl p-6 space-y-4">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-400" />
@@ -73,6 +67,7 @@ export default function FundApplicationClient({ existing, managedFund }: Props)
{withdrawing ? 'Withdrawing…' : 'Withdraw application'}
</button>
</div>
</div>
)
}
@@ -94,6 +89,19 @@ export default function FundApplicationClient({ existing, managedFund }: Props)
}
return (
<div className="space-y-4">
{managedFund && (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 flex items-start gap-3">
<CheckCircle className="h-4 w-4 text-green-400 mt-0.5 shrink-0" />
<p className="text-sm text-slate-400">
You already manage{' '}
<Link href={`/fund/${managedFund.slug}`} className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
{managedFund.name}
</Link>
. You can still apply for an additional fund below.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-4">
<div className="flex items-center gap-2 text-slate-300">
<Building2 className="h-5 w-5 text-indigo-400" />
@@ -143,5 +151,6 @@ export default function FundApplicationClient({ existing, managedFund }: Props)
{loading ? 'Submitting…' : 'Submit Application'}
</button>
</form>
</div>
)
}
+68 -11
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { formatCurrency, formatNumber } from '@/lib/utils'
interface Props {
@@ -11,18 +12,20 @@ interface Props {
shortPosition: { shares: number; avgBuyPrice: number } | null
fundId?: string
fundName?: string
managedFunds?: { slug: string; name: string }[]
maxPositionShares: number
maxPositionValue: number
}
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, maxPositionShares, maxPositionValue }: Props) {
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, managedFunds, maxPositionShares, maxPositionValue }: Props) {
const router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showFundMenu, setShowFundMenu] = useState(false)
const sharesNum = parseFloat(shares) || 0
const cost = sharesNum * hashtag.currentPrice
@@ -65,13 +68,53 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
{fundName && (
{fundName ? (
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
<span className="text-lg">🏦</span>
Trading as <span className="font-semibold">{fundName}</span>
<span className="text-indigo-500 ml-auto">Fund mode</span>
<span className="text-base">🏦</span>
<span>Trading as <span className="font-semibold">{fundName}</span></span>
<Link
href={`/hashtag/${hashtag.tag}`}
className="ml-auto text-indigo-400 hover:text-indigo-200 transition-colors"
>
Exit fund mode ×
</Link>
</div>
) : managedFunds && managedFunds.length === 1 ? (
<Link
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(managedFunds[0].slug)}`}
className="flex items-center gap-2 text-xs border border-surface-border rounded-lg px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as <span className="font-medium text-slate-200">{managedFunds[0].name}</span></span>
<span className="ml-auto"></span>
</Link>
) : managedFunds && managedFunds.length > 1 ? (
<div className="text-xs border border-surface-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowFundMenu((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as a fund</span>
<span className="ml-auto text-slate-600">{showFundMenu ? '▲' : '▼'}</span>
</button>
{showFundMenu && (
<div className="border-t border-surface-border divide-y divide-surface-border">
{managedFunds.map((f) => (
<Link
key={f.slug}
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(f.slug)}`}
className="flex items-center justify-between px-3 py-2 font-medium text-indigo-300 hover:text-indigo-200 hover:bg-surface transition-colors"
>
<span>{f.name}</span>
<span className="text-slate-500"></span>
</Link>
))}
</div>
)}
</div>
) : null}
<div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400">
@@ -85,7 +128,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
<button
key={t}
onClick={() => { setTab(t); setShares(''); setError('') }}
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors ${
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors leading-tight ${
tab === t
? t.startsWith('BUY')
? 'bg-emerald-600 text-white'
@@ -93,7 +136,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t.replace('_', ' ')}
<span className="block sm:inline">{t.split('_')[0]}</span>
<span className="hidden sm:inline"> </span>
<span className="block sm:inline">{t.split('_')[1]}</span>
</button>
))}
</div>
@@ -102,23 +147,35 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">LONG position</p>
{longPosition ? (
{longPosition ? (() => {
const pnl = (hashtag.currentPrice - longPosition.avgBuyPrice) * longPosition.shares
return (
<>
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
</p>
</>
) : (
)
})() : (
<p className="text-slate-600">None</p>
)}
</div>
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">SHORT position</p>
{shortPosition ? (
{shortPosition ? (() => {
const pnl = (shortPosition.avgBuyPrice - hashtag.currentPrice) * shortPosition.shares
return (
<>
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
</p>
</>
) : (
)
})() : (
<p className="text-slate-600">None</p>
)}
</div>
+62 -4
View File
@@ -18,6 +18,8 @@ const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE
const FUND_MAX_POSITION_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 10)
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
interface Props {
@@ -25,6 +27,51 @@ interface Props {
searchParams: { fund?: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const hashtag = await prisma.hashtag.findUnique({
where: { tag },
select: {
displayTag: true,
currentPrice: true,
isActive: true,
priceHistory: { orderBy: { recordedAt: 'desc' }, take: 2, select: { price: true } },
},
})
const displayTag = hashtag?.displayTag ?? tag
const price = hashtag?.currentPrice ?? 0
const prevPrice = hashtag?.priceHistory[1]?.price ?? null
const changePct = prevPrice && prevPrice > 0
? ((price - prevPrice) / prevPrice) * 100
: null
const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 }).format(price)
const changeStr = changePct !== null
? ` ${changePct >= 0 ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%`
: ''
const status = hashtag?.isActive === false ? ' · inactive' : ''
const title = `#${displayTag}${priceStr}${changeStr}`
const description = `Trade #${displayTag} on HashEx. Current price: ${priceStr}${changeStr}${status}. Prices driven by real Mastodon activity.`
const imageUrl = `/api/og/hashtag/${encodeURIComponent(tag)}`
return {
title,
description,
openGraph: {
title,
description,
images: [{ url: imageUrl, width: 1200, height: 630, alt: `#${displayTag} price chart` }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [imageUrl],
},
}
}
export default async function HashtagPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
@@ -35,11 +82,11 @@ export default async function HashtagPage({ params, searchParams }: Props) {
where: { tag },
include: {
priceHistory: {
orderBy: { recordedAt: 'asc' },
take: 200,
orderBy: { recordedAt: 'desc' },
take: 192, // 192 updates = 2 days of 15-min intervals
},
_count: {
select: { positions: true },
select: { positions: { where: { shares: { gt: 0 } } } },
},
relatedFrom: {
orderBy: { coOccurrences: 'desc' },
@@ -84,6 +131,16 @@ export default async function HashtagPage({ params, searchParams }: Props) {
}
}
// When not in fund mode, fetch funds this user manages for the fund-mode switcher
let managedFunds: { slug: string; name: string }[] = []
if (session && !fundContext) {
const managerships = await prisma.fundManager.findMany({
where: { userId: session.user.id },
include: { fund: { select: { slug: true, name: true } } },
})
managedFunds = managerships.map((m) => ({ slug: m.fund.slug, name: m.fund.name }))
}
// Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) {
return (
@@ -154,7 +211,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
<PriceChart
data={hashtag.priceHistory.map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
data={hashtag.priceHistory.slice().reverse().map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
height={280}
/>
</div>
@@ -199,6 +256,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
fundName={fundContext?.name}
maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES}
maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE}
managedFunds={managedFunds}
/>
) : (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
+1
View File
@@ -7,6 +7,7 @@ import { Navbar } from '@/components/Navbar'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
title: 'HashEx — The Hashtag Exchange',
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.',
}
+18
View File
@@ -7,8 +7,26 @@ import Link from 'next/link'
import { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
export const metadata: Metadata = {
title: 'Leaderboard — HashEx',
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
openGraph: {
title: 'Leaderboard — HashEx',
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
images: [{ url: '/api/og/leaderboard', width: 1200, height: 630, alt: 'HashEx leaderboard' }],
},
twitter: {
card: 'summary_large_image',
title: 'Leaderboard — HashEx',
description: 'Top traders by net worth on HashEx, the hashtag stock exchange.',
images: ['/api/og/leaderboard'],
},
}
async function getLeaderboard() {
const users = await prisma.user.findMany({
where: { isFund: false, isHidden: false },
+6
View File
@@ -183,6 +183,12 @@ export default async function HomePage() {
<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
<Link
href="/positions"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{holdings.biggestGain && (
+2 -2
View File
@@ -88,7 +88,7 @@ export default async function PositionsPage({
displayTag: true,
currentPrice: true,
priceHistory: {
orderBy: { recordedAt: 'asc' },
orderBy: { recordedAt: 'desc' },
take: 20,
select: { price: true },
},
@@ -148,7 +148,7 @@ export default async function PositionsPage({
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)
const sparkPrices = pos.hashtag.priceHistory.slice().reverse().map((h) => h.price)
return { ...pos, pnl, costBasis, currentValue, pnlPct, sparkPrices }
})
+54 -32
View File
@@ -13,12 +13,34 @@ import CloseAccountForm from './CloseAccountForm'
import ResetAccountForm from './ResetAccountForm'
import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
interface Props {
params: { username: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const username = decodeURIComponent(params.username).toLowerCase()
const user = await prisma.user.findUnique({
where: { username },
select: { displayUsername: true, balance: true, _count: { select: { trades: true } } },
})
const displayName = user?.displayUsername ?? username
const title = `${displayName} — HashEx Profile`
const description = user
? `Check out ${displayName}'s trading profile on HashEx.`
: `HashEx trader profile for @${username}.`
const imageUrl = `/api/og/profile/${encodeURIComponent(username)}`
return {
title,
description,
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${displayName}'s profile` }] },
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
}
}
export default async function ProfilePage({ params }: Props) {
const session = await getServerSession(authOptions)
const username = decodeURIComponent(params.username).toLowerCase()
@@ -138,7 +160,7 @@ export default async function ProfilePage({ params }: Props) {
<p className="text-sm text-slate-400">total portfolio value</p>
{lotteryCount > 0 && (
<p className="text-xs text-amber-400 mt-1">
🎰 {formatCurrency(lotteryWinnings)} from Lucky Dip ({lotteryCount} spin{lotteryCount !== 1 ? 's' : ''})
🎰 {formatCurrency(lotteryWinnings)} from Lucky Dip ({lotteryCount} win{lotteryCount !== 1 ? 's' : ''})
</p>
)}
</div>
@@ -187,6 +209,37 @@ export default async function ProfilePage({ params }: Props) {
</div>
)}
{/* Funds managed — only shown to the profile owner */}
{isOwn && user.managedFunds.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Building2 className="h-5 w-5 text-indigo-400" />
Funds you manage
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{user.managedFunds.map(({ fund }) => (
<div key={fund.id} className="flex items-center justify-between px-4 py-3">
<Link
href={`/fund/${fund.slug}`}
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
>
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
{fund.name}
</Link>
<Link
href={`/stocks?fund=${fund.slug}`}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Trade as this fund
</Link>
</div>
))}
</div>
</div>
</section>
)}
{/* Positions */}
{user.positions.length > 0 && (
<section>
@@ -240,37 +293,6 @@ export default async function ProfilePage({ params }: Props) {
</section>
)}
{/* Funds managed — only shown to the profile owner */}
{isOwn && user.managedFunds.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Building2 className="h-5 w-5 text-indigo-400" />
Funds you manage
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{user.managedFunds.map(({ fund }) => (
<div key={fund.id} className="flex items-center justify-between px-4 py-3">
<Link
href={`/fund/${fund.slug}`}
className="font-medium hover:text-indigo-300 transition-colors flex items-center gap-2"
>
<Building2 className="h-3.5 w-3.5 text-indigo-400 shrink-0" />
{fund.name}
</Link>
<Link
href={`/stocks?fund=${fund.slug}`}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Trade as this fund
</Link>
</div>
))}
</div>
</div>
</section>
)}
{/* Trade history */}
{user.trades.length > 0 && (
<section>
+2 -2
View File
@@ -86,7 +86,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: true } },
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
},
})
@@ -138,7 +138,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: true } },
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
},
})
.then((rows) =>
+33 -5
View File
@@ -15,15 +15,28 @@ interface PageProps {
export default async function GlobalTradesPage({ searchParams }: PageProps) {
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
const tradeWhere = {
OR: [
{ hashtagId: { not: null as string | null }, type: { not: 'LOTTERY_WIN' as const } },
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as ('FUND_INVEST' | 'FUND_REDEEM')[] } },
],
}
const [total, trades] = await Promise.all([
prisma.trade.count({ where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } } }),
prisma.trade.count({ where: tradeWhere }),
prisma.trade.findMany({
where: { hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' } },
where: {
OR: [
{ hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' as const } },
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as const } },
],
},
orderBy: { createdAt: 'desc' },
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
include: {
hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
user: { select: { username: true, displayUsername: true, isFund: true } },
},
}),
@@ -49,19 +62,34 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
? 'bg-orange-500/15 text-orange-400'
: (t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM')
? 'bg-indigo-500/15 text-indigo-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace('_', ' ')}
{(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span>
{(t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM') ? (
t.fund ? (
<Link
href={`/fund/${t.fund.slug}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
>
{t.fund.name}
</Link>
) : (
<span className="text-slate-500 font-medium flex-1 min-w-0">Deleted Fund</span>
)
) : (
<Link
href={`/hashtag/${t.hashtag!.tag}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
>
#{t.hashtag!.displayTag}
</Link>
)}
<span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span>
</div>
{/* Secondary row: user · time (left) shares @ price (right) */}
@@ -79,8 +107,8 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
</div>
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
</div>
{/* PnL: sell and liquidation trades */}
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') && (
{/* PnL: sell, liquidation, and fund redeem trades */}
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT' || t.type === 'FUND_REDEEM') && (
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
)}
</div>
+42 -26
View File
@@ -3,30 +3,34 @@
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useState, useRef, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } 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()
function NavSearchInner() {
const router = useRouter()
const searchParams = useSearchParams()
const fundSlug = searchParams.get('fund')
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()
const tag = normalizeTag(query)
if (tag) {
router.push(`/hashtag/${tag}`)
function navigate(tag: string) {
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
router.push(url)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
const tag = normalizeTag(query)
if (tag) navigate(tag)
}
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -52,16 +56,6 @@ export function Navbar() {
}
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">
<div className="flex items-center justify-between h-14 gap-4">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<TrendingUp className="h-6 w-6 text-indigo-500" />
<span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
@@ -71,7 +65,7 @@ export function Navbar() {
onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder="#hashtag"
placeholder={fundSlug ? `#hashtag (as ${fundSlug})` : '#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 && (
@@ -80,12 +74,7 @@ export function Navbar() {
<button
key={s.tag}
type="button"
onMouseDown={() => {
router.push(`/hashtag/${s.tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}}
onMouseDown={() => navigate(s.tag)}
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>
@@ -96,6 +85,33 @@ export function Navbar() {
)}
</div>
</form>
)
}
export function Navbar() {
const { data: session } = useSession()
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">
<div className="flex items-center justify-between h-14 gap-4">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<TrendingUp className="h-6 w-6 text-indigo-500" />
<span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link>
{/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
<Suspense fallback={
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
</div>
</div>
}>
<NavSearchInner />
</Suspense>
{/* Right section */}
<div className="flex items-center gap-3 shrink-0">
+68 -17
View File
@@ -70,13 +70,23 @@ export async function getPostsPerHour(tag: string): Promise<number> {
* Returns posts-per-hour AND a sorted list of co-occurring tag names
* (lowercased, excluding the queried tag itself).
*
* Strategy:
* - Paginate until we have at least one post older than 1 hour (a complete picture),
* OR we exhaust the timeline, OR we hit MAX_PAGES_PER_HASHTAG.
* - If the oldest fetched post is >= 1 hour old: postsPerHour = count of posts in the
* last hour (direct measurement over a full window).
* - If all fetched posts are within the last hour (hit page limit or timeline exhausted
* with a narrow window): extrapolate — postsPerHour = count / (coveredHours).
* Pagination strategy:
* - Keep fetching pages until >= 50% of posts in a page fall outside the 1-hour window,
* OR the timeline is exhausted, OR MAX_PAGES_PER_HASHTAG is reached.
* - The 50% rule handles federated out-of-order posts gracefully: Mastodon timelines are
* ordered by post ID (local receive time), not created_at. A remote post from hours or
* even years ago can arrive late, get a fresh ID, and appear at the top of the stream.
* A minority of such posts won't trigger the stop condition; only once the majority of
* a page is old content do we consider the 1-hour window fully covered.
* - After collecting all pages, sort by created_at and filter to the last hour for an
* accurate count regardless of any remaining ordering noise.
*
* PPH calculation:
* - Crossed horizon (direct): we have a full window — count posts with created_at >= cutoff.
* - Hit page cap without crossing (burst): more posts exist beyond what we fetched —
* extrapolate from the covered time span (count / coveredHours).
* - Timeline exhausted without crossing (sparse): all posts in the last hour are accounted
* for — use the raw count directly (no extrapolation).
*/
export async function getPostsData(
tag: string,
@@ -89,6 +99,7 @@ export async function getPostsData(
let allPosts: MastodonPost[] = []
let maxId: string | undefined
let hitPageCap = false
for (let page = 0; page < maxPages; page++) {
const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
@@ -99,28 +110,46 @@ export async function getPostsData(
// End of timeline or no more pages
if (posts.length < postLimit || !nextMaxId) break
// If the oldest post in this batch is already beyond 1 hour, we have a full window
const oldestInBatch = Math.min(...posts.map((p) => new Date(p.created_at).getTime()))
if (oldestInBatch < cutoff) break
// Stop when >= 50% of this page's posts are outside the 1-hour window.
// A handful of old federated posts won't trigger this; once the majority of a page
// is old content we have a reliable picture of the last hour.
const outsideWindow = posts.filter((p) => new Date(p.created_at).getTime() < cutoff).length
if (outsideWindow / posts.length >= 0.5) break
maxId = nextMaxId
if (page === maxPages - 1) hitPageCap = true
}
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false }
// Sort globally by created_at so the window filter is accurate regardless of federation order
allPosts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const newestMs = Math.max(...times)
const oldestMs = Math.min(...times)
const newestMs = times[0]
const oldestMs = times[times.length - 1]
let postsPerHour: number
if (oldestMs < cutoff) {
// We reached (or passed) the 1-hour horizon — count posts within the last hour directly
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
} else if (hitPageCap) {
// Hit the page cap and never reached the horizon — burst scenario, more posts exist
// beyond what we fetched. Extrapolate using only in-window posts:
// rate = inWindowCount / coveredHours, where coveredHours = (now - oldestInWindowPost) / ONE_HOUR_MS
// This gives posts-per-hour as if the same rate continued for the full 60 minutes.
// Minimum 1-minute covered span to avoid divide-by-zero on a single-post window.
const inWindowPosts = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff)
const oldestInWindowMs = inWindowPosts.length > 0
? Math.min(...inWindowPosts.map((p) => new Date(p.created_at).getTime()))
: newestMs
const coveredMs = Math.max(now - oldestInWindowMs, 60_000)
postsPerHour = inWindowPosts.length / (coveredMs / ONE_HOUR_MS)
} else {
// All posts are within the last hour (burst scenario or very sparse tag).
// Extrapolate from the covered span. Minimum 1-minute span to avoid divide-by-zero.
const coveredMs = Math.max(newestMs - oldestMs, 60_000)
postsPerHour = allPosts.length / (coveredMs / ONE_HOUR_MS)
// Timeline exhausted — these are all the posts that exist within the last hour.
// Use the raw count directly; extrapolating would inflate a sparse tag.
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
}
// Count co-occurring tags from the API tags object (authoritative for membership)
@@ -165,6 +194,28 @@ export async function getPostsData(
if (topCount / total >= 0.5) displayTag = topVariant
}
return { postsPerHour, relatedTags, displayTag, hasAnyPosts: true }
const pagesFetched = hitPageCap
? maxPages
: allPosts.length === 0
? 0
: Math.ceil(allPosts.length / postLimit)
const relAge = (ms: number) => {
const diffMs = now - ms
const d = Math.floor(diffMs / 86_400_000)
const h = Math.floor((diffMs % 86_400_000) / 3_600_000)
const m = Math.floor((diffMs % 3_600_000) / 60_000)
return `${d}d ${h}h ${m}m ago`
}
const inWindowCount = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
const method = oldestMs < cutoff ? 'direct' : hitPageCap ? 'extrapolated' : 'raw'
console.log(
`[mastodon] #${tag} — pages: ${pagesFetched}, posts: ${allPosts.length} (${inWindowCount} in-window), ` +
`between: ${relAge(oldestMs)} - ${relAge(newestMs)}, ` +
`pph: ${postsPerHour.toFixed(2)} (${method})`,
)
return { postsPerHour: Math.round(postsPerHour * 10) / 10, relatedTags, displayTag, hasAnyPosts: true }
}
+20 -10
View File
@@ -1,17 +1,27 @@
/**
* Converts posts-per-hour to a share price.
* Converts posts-per-hour to a share price using a saturating (Michaelis-Menten) curve.
*
* Linear scale: $0.25 per post/hour, minimum $0.25.
* Examples:
* 1 post/hr → $0.25
* 10 posts/hr → $2.50
* 100 → $25.00
* 1000 → $250.00
* 12 000 (viral #happynewyear) → $3 000
* Formula: price = base * pph / (1 + k * pph)
* where k is chosen so the curve hits $250 at 3 600 PPH.
*
* Anchor points:
* 1 post/hr → ~$0.25
* 10 posts/hr~$2.48
* 100 posts/hr → ~$23.32
* 1 000 → ~$145
* 3 600 (viral) → $250.00 (design target)
* Asymptote → ~$346
*
* Floor: $0.25 for ≤ 1 PPH.
*/
export function calcPrice(postsPerHour: number): number {
if (postsPerHour <= 0) return 0.25
return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100)
if (postsPerHour <= 1) return 0.25
const base = 0.25 // The base price at low volumes (1 PPH)
const anchor = 3600 // PPH at which we want the target price (1 PPS)
const target = 250 // price at the anchor PPH
const k = ((base * anchor / target) - 1) / anchor
const price = base * postsPerHour / (1 + k * postsPerHour)
return Math.max(0.25, Math.round(price * 100) / 100)
}
/**
+6 -2
View File
@@ -41,7 +41,11 @@ export function pnlColor(value: number): string {
return 'text-slate-400'
}
/** Normalize a hashtag: lowercase, strip leading #, trim whitespace */
/** Normalize a hashtag: lowercase, strip leading #, remove all whitespace and
* any character that isn't a letter, digit, or underscore. */
export function normalizeTag(raw: string): string {
return raw.trim().replace(/^#+/, '').toLowerCase()
return raw
.replace(/^#+/, '') // strip leading #
.replace(/[\s]/g, '') // remove all whitespace
.toLowerCase()
}
-1
View File
@@ -2,7 +2,6 @@ export { default } from 'next-auth/middleware'
export const config = {
matcher: [
'/profile/:path*',
'/positions',
'/history',
'/admin/:path*',
+41 -11
View File
@@ -144,25 +144,38 @@ const priceWorker = new Worker(
}
const shouldDeactivate = ttlExpired && ownerCount === 0
await prisma.hashtag.update({
const floorPrice = calcPrice(0)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId },
data: {
zeroCount: newZeroCount,
isActive: shouldDeactivate ? false : hashtag.isActive,
lastUpdated: new Date(),
},
})
}),
// Record floor price in history while the stock is still active so the chart has no gaps
...(!shouldDeactivate ? [prisma.priceHistory.create({
data: { hashtagId, price: floorPrice, postsPerHour: 0 },
})] : []),
])
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 TTL expired and no holders, record final price then deactivate
if (ttlExpired && ownerCount === 0) {
await prisma.hashtag.update({
const finalPrice = calcPrice(postsPerHour)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId },
data: { isActive: false, lastUpdated: new Date() },
})
console.log(`[price] #${tag} deactivated — TTL expired, no holders`)
data: { currentPrice: finalPrice, isActive: false, lastUpdated: new Date() },
}),
prisma.priceHistory.create({
data: { hashtagId, price: finalPrice, postsPerHour },
}),
])
console.log(`[price] #${tag} deactivated — TTL expired, no holders (final price $${finalPrice.toFixed(2)})`)
return
}
@@ -234,7 +247,7 @@ const maintenanceWorker = new Worker(
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
const MAX_RESEARCH_POINTS = 10
const users = await prisma.user.findMany({ select: { id: true, balance: true, researchPoints: true } })
const users = await prisma.user.findMany({ where: { isFund: false }, select: { id: true, balance: true, researchPoints: true } })
for (const user of users) {
const points = dailyResearchPoints(user.balance)
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
@@ -291,6 +304,23 @@ const maintenanceWorker = new Worker(
console.log(
`[maintenance] pruned snapshots — fund NAV: ${deletedFundNav.count} rows, user portfolio: ${deletedUserPortfolio.count} rows (>7d)`,
)
// ── Related hashtag pruning ────────────────────────────────────────────
// 1. Null-target records: the related hashtag was deleted (onDelete: SetNull)
// or was never tracked. Delete unconditionally — the upsert in the price
// worker will recreate them if the co-occurrence is seen again.
// 2. Weak associations: low co-occurrence records that were never reinforced,
// pruned after 30 days of inactivity.
const relatedCutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const [deletedNullTarget, deletedWeak] = await Promise.all([
prisma.relatedHashtag.deleteMany({ where: { relatedId: null } }),
prisma.relatedHashtag.deleteMany({
where: { relatedId: { not: null }, coOccurrences: { lt: 5 }, updatedAt: { lt: relatedCutoff } },
}),
])
console.log(
`[maintenance] pruned relatedHashtag — ${deletedNullTarget.count} null-target, ${deletedWeak.count} weak (>30d, <5 co-occurrences)`,
)
},
{ connection: redisOpts() },
)
@@ -472,16 +502,16 @@ async function setupRepeatableJobs() {
},
)
// Daily maintenance — every day at 00:05 UTC
// Daily maintenance — every day at 00:00 Eastern (midnight)
await maintenanceQueue.add(
'daily-maintenance',
{},
{
repeat: { pattern: '5 0 * * *' },
repeat: { pattern: '0 0 * * *' },
},
)
// Hourly fund NAV snapshot — every hour on the hour
// Hourly fund NAV snapshot — every 15 minutes
await fundNavSnapshotQueue.add(
'fund-nav-snapshot',
{},