Compare commits

..

33 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
ThaMunsta c1ca92b8a0 fix: update About link positioning and styling in HomePage component
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-20 21:05:19 -04:00
ThaMunsta c6a0cf51a9 feat: implement fund application process with admin review and user submission 2026-03-20 20:58:09 -04:00
36 changed files with 1579 additions and 233 deletions
+15 -10
View File
@@ -152,27 +152,32 @@ All variables are documented in `.env.example`. Key ones:
## Pricing Formula ## 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 | | Posts/hr | Price |
|---|---| |---|---|
| 1 | $0.25 | | 1 | ~$0.25 |
| 10 | $2.50 | | 10 | ~$2.48 |
| 100 | $25.00 | | 100 | ~$23.32 |
| 1,000 | $250.00 | | 1,000 | ~$145 |
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 | | 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 ## 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 milestones unlock extra daily points:
| Balance | 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-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-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). The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
+10
View File
@@ -29,6 +29,7 @@ model User {
fund HedgeFund? fund HedgeFund?
fundInvestments FundInvestment[] fundInvestments FundInvestment[]
portfolioHistory UserPortfolioHistory[] portfolioHistory UserPortfolioHistory[]
fundApplication FundApplication?
} }
model HedgeFund { model HedgeFund {
@@ -213,3 +214,12 @@ enum TradeType {
FUND_INVEST // invested cash into a hedge fund FUND_INVEST // invested cash into a hedge fund
FUND_REDEEM // redeemed shares from a hedge fund FUND_REDEEM // redeemed shares from a hedge fund
} }
model FundApplication {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
fundName String
reason String
createdAt DateTime @default(now())
}
+2
View File
@@ -20,6 +20,7 @@ services:
MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}" MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}"
FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}" FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}"
FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}" FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}"
TZ: "America/Toronto"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -43,6 +44,7 @@ services:
MASTODON_POST_LIMIT: "${MASTODON_POST_LIMIT:-20}" MASTODON_POST_LIMIT: "${MASTODON_POST_LIMIT:-20}"
PRICE_HISTORY_ACTIVE_DAYS: "${PRICE_HISTORY_ACTIVE_DAYS:-7}" PRICE_HISTORY_ACTIVE_DAYS: "${PRICE_HISTORY_ACTIVE_DAYS:-7}"
PRICE_HISTORY_INACTIVE_HOURS: "${PRICE_HISTORY_INACTIVE_HOURS:-24}" PRICE_HISTORY_INACTIVE_HOURS: "${PRICE_HISTORY_INACTIVE_HOURS:-24}"
TZ: "America/Toronto"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+23 -5
View File
@@ -71,12 +71,22 @@ export default function AboutPage() {
<Section title="How Prices Work" icon={Coins}> <Section title="How Prices Work" icon={Coins}>
<div className="space-y-2 text-sm text-slate-300"> <div className="space-y-2 text-sm text-slate-300">
<p> <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> </p>
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300"> <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 = max($0.25, posts_per_hour × $0.25) price = (0.25 × pph) / (1 + k × pph) &nbsp;·&nbsp; floor $0.25
</div> </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 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 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. home page if any of your positions are at risk. Research it again to reactivate it.
@@ -149,6 +159,12 @@ export default function AboutPage() {
Fund shares are stored to 6 decimal places. Fund accounts cannot sign in directly and do not earn Fund shares are stored to 6 decimal places. Fund accounts cannot sign in directly and do not earn
research points or play the lottery. research points or play the lottery.
</p> </p>
<p className="text-xs">
Want to run your own fund?{' '}
<Link href="/fund/apply" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
Apply here
</Link>
</p>
</div> </div>
</Section> </Section>
@@ -175,7 +191,9 @@ export default function AboutPage() {
<Section title="Lucky Dip" icon={Shuffle}> <Section title="Lucky Dip" icon={Shuffle}>
<p className="text-sm text-slate-300"> <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. 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> </p>
</Section> </Section>
@@ -0,0 +1,165 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
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 {
id: string
fundName: string
reason: string
createdAt: string
user: Applicant
}
export default function AdminFundApplications({ applications: initial }: { applications: Application[] }) {
const router = useRouter()
const [applications, setApplications] = useState<Application[]>(initial)
const [expanded, setExpanded] = useState<string | null>(null)
const [startingBalance, setStartingBalance] = useState('10000')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function approve(app: Application) {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/fund-applications/${app.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve', startingBalance: parseFloat(startingBalance) || 0 }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setApplications(applications.filter((a) => a.id !== app.id))
setExpanded(null)
router.refresh()
}
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}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'deny' }),
})
setLoading(false)
if (res.ok) {
setApplications(applications.filter((a) => a.id !== id))
router.refresh()
}
}
if (applications.length === 0) {
return (
<p className="text-slate-500 text-sm">No pending fund applications.</p>
)
}
return (
<div className="space-y-3">
{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) => {
const isOpen = expanded === app.id
return (
<div key={app.id} className="bg-surface-card border border-amber-500/20 rounded-xl overflow-hidden">
{/* 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">
<p className="font-medium text-sm">{app.fundName}</p>
<p className="text-xs text-slate-500">
by{' '}
<span className="text-slate-300">{app.user.displayUsername ?? app.user.username}</span>
{' '}· {new Date(app.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2 ml-4 shrink-0">
<button
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>
{isOpen ? <ChevronUp className="h-4 w-4 text-slate-400" /> : <ChevronDown className="h-4 w-4 text-slate-400" />}
</div>
</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>
<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>
<label className="text-xs text-slate-500 block mb-1">Starting Balance ($)</label>
<input
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"
/>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => approve(app)}
disabled={loading}
className="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs rounded-lg disabled:opacity-50 transition-colors"
>
Confirm Approval
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
+37 -2
View File
@@ -1,10 +1,12 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import AdminFundActions from './AdminFundActions' import AdminFundActions from './AdminFundActions'
import AdminFundApplications from './AdminFundApplications'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function AdminFundsPage() { export default async function AdminFundsPage() {
const funds = await prisma.hedgeFund.findMany({ const [funds, applications] = await Promise.all([
prisma.hedgeFund.findMany({
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
include: { include: {
user: { select: { balance: true } }, user: { select: { balance: true } },
@@ -13,12 +15,45 @@ export default async function AdminFundsPage() {
orderBy: { addedAt: 'asc' }, orderBy: { addedAt: 'asc' },
}, },
}, },
}) }),
prisma.fundApplication.findMany({
orderBy: { createdAt: 'asc' },
include: {
user: {
select: {
id: true,
username: true,
displayUsername: true,
managedFunds: { select: { fund: { select: { name: true, slug: true } } } },
},
},
},
}),
])
const serialisedApplications = applications.map((a) => ({
...a,
createdAt: a.createdAt.toISOString(),
}))
return ( return (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
Fund Applications
{applications.length > 0 && (
<span className="text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded-full px-2 py-0.5">
{applications.length}
</span>
)}
</h2>
<AdminFundApplications applications={serialisedApplications} />
</div>
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold">Hedge Funds</h2> <h2 className="text-lg font-semibold">Hedge Funds</h2>
<AdminFundActions funds={funds} /> <AdminFundActions funds={funds} />
</div> </div>
</div>
) )
} }
+29 -12
View File
@@ -67,26 +67,43 @@ export default async function AdminOverviewPage() {
Recent trades Recent trades
</h2> </h2>
<div className="divide-y divide-surface-border"> <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 key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
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'
}`}
>
{t.type.replace(/_/g, ' ')} {t.type.replace(/_/g, ' ')}
</span> </span>
<span className="text-slate-300">{t.user.username}</span> <span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500"> {label && <span className="text-slate-500">{label}</span>}
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
</span>
</div> </div>
<span>{formatCurrency(t.total)}</span> <span>{formatCurrency(t.total)}</span>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
</div> </div>
+5 -2
View File
@@ -29,7 +29,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
skip, skip,
take: pageSize, take: pageSize,
include: { include: {
_count: { select: { positions: true, trades: true } }, _count: { select: { positions: { where: { shares: { gt: 0 } } }, trades: true } },
}, },
}), }),
prisma.hashtag.count({ where }), prisma.hashtag.count({ where }),
@@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) {
{hashtags.map((h) => ( {hashtags.map((h) => (
<tr key={h.id} className="hover:bg-surface-hover"> <tr key={h.id} className="hover:bg-surface-hover">
<td className="px-4 py-3"> <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} #{h.displayTag}
</a> </a>
</td> </td>
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
function toSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
const approveSchema = z.object({
action: z.literal('approve'),
startingBalance: z.number().min(0).default(0),
})
const denySchema = z.object({
action: z.literal('deny'),
})
/**
* POST /api/admin/fund-applications/[applicationId]
* action: 'approve' — creates the fund, adds applicant as manager, deletes the application
* action: 'deny' — deletes the application
*/
export async function POST(
req: NextRequest,
{ params }: { params: { applicationId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const application = await prisma.fundApplication.findUnique({
where: { id: params.applicationId },
include: { user: { select: { id: true, username: true } } },
})
if (!application) {
return NextResponse.json({ error: 'Application not found' }, { status: 404 })
}
const body = await req.json()
if (body.action === 'deny') {
await prisma.fundApplication.delete({ where: { id: params.applicationId } })
return NextResponse.json({ ok: true })
}
const parsed = approveSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
}
const { startingBalance } = parsed.data
const name = application.fundName
const slug = toSlug(name)
const shadowUsername = `fund:${slug}`
// Check for conflicts
const [existingFund, existingSlug, existingUser] = await Promise.all([
prisma.hedgeFund.findFirst({ where: { name: { equals: name, mode: 'insensitive' } } }),
prisma.hedgeFund.findUnique({ where: { slug } }),
prisma.user.findUnique({ where: { username: shadowUsername } }),
])
if (existingFund) return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
if (existingSlug) return NextResponse.json({ error: 'A fund with that slug already exists.' }, { status: 409 })
if (existingUser) return NextResponse.json({ error: 'Shadow user conflict.' }, { status: 409 })
const fund = await prisma.$transaction(async (tx) => {
const shadowUser = await tx.user.create({
data: {
username: shadowUsername,
displayUsername: name,
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10),
balance: startingBalance,
isFund: true,
},
})
const newFund = await tx.hedgeFund.create({
data: { name, slug, userId: shadowUser.id, sharesOutstanding: 0 },
include: {
user: { select: { balance: true } },
managers: { include: { user: { select: { id: true, username: true, displayUsername: true } } } },
},
})
await tx.fundManager.create({
data: { fundId: newFund.id, userId: application.userId },
})
await tx.fundApplication.delete({ where: { id: application.id } })
return { ...newFund, managers: [{ id: 'new', userId: application.userId, user: application.user }] }
})
return NextResponse.json(fund, { status: 201 })
}
+1
View File
@@ -61,6 +61,7 @@ export async function POST(req: NextRequest) {
displayUsername: name, displayUsername: name,
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random, non-loginable
balance: initialBalance, balance: initialBalance,
researchPoints: 0,
isFund: true, isFund: true,
}, },
}) })
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const submitSchema = z.object({
fundName: z.string().min(1).max(60),
reason: z.string().min(10).max(1000),
})
/**
* GET /api/fund-applications
* Returns the current user's pending application, or null.
*/
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const application = await prisma.fundApplication.findUnique({
where: { userId: session.user.id },
})
return NextResponse.json(application)
}
/**
* POST /api/fund-applications
* Submit a fund application. One per user at a time.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const parsed = submitSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
}
const { fundName, reason } = parsed.data
try {
const application = await prisma.fundApplication.create({
data: { userId: session.user.id, fundName: fundName.trim(), reason: reason.trim() },
})
return NextResponse.json(application, { status: 201 })
} catch {
// Unique constraint violation — already has a pending application
return NextResponse.json({ error: 'You already have a pending application.' }, { status: 409 })
}
}
/**
* DELETE /api/fund-applications
* Withdraw the current user's pending application.
*/
export async function DELETE() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
await prisma.fundApplication.deleteMany({ where: { userId: session.user.id } })
return NextResponse.json({ ok: true })
}
+2 -2
View File
@@ -68,9 +68,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const [updatedInvestor] = await prisma.$transaction([ const [updatedInvestor] = await prisma.$transaction([
// Deduct from investor (returns updated user with new balance) // 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 // 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 // Upsert FundInvestment record
prisma.fundInvestment.upsert({ prisma.fundInvestment.upsert({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, 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 }) 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 portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG' const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice ? p.shares * p.hashtag.currentPrice
@@ -61,9 +64,9 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const [updatedInvestor] = await prisma.$transaction([ const [updatedInvestor] = await prisma.$transaction([
// Return cash to investor // 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 // 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 // Update or delete FundInvestment
...(remainingShares > 0 ...(remainingShares > 0
? [prisma.fundInvestment.update({ ? [prisma.fundInvestment.update({
+4 -4
View File
@@ -16,9 +16,9 @@ function buildPrizes(): number[] {
function isSameDay(a: Date, b: Date) { function isSameDay(a: Date, b: Date) {
return ( return (
a.getUTCFullYear() === b.getUTCFullYear() && a.getFullYear() === b.getFullYear() &&
a.getUTCMonth() === b.getUTCMonth() && a.getMonth() === b.getMonth() &&
a.getUTCDate() === b.getUTCDate() a.getDate() === b.getDate()
) )
} }
@@ -26,7 +26,7 @@ function isSameDay(a: Date, b: Date) {
* POST /api/lottery/pick * POST /api/lottery/pick
* Body: { box: number } (0-indexed, 024) * 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) { export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions) 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 InvestPanel from './InvestPanel'
import { PriceChart } from '@/components/PriceChart' import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic' 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 } }) { export default async function FundPage({ params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
const slug = decodeURIComponent(params.slug).toLowerCase() 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. Search for a hashtag below and trade using the fund&apos;s balance.
All positions and profit belong to the fund. All positions and profit belong to the fund.
</p> </p>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{positions.map((p) => ( {positions.map((p) => (
<Link <Link
key={p.id} key={p.id}
@@ -0,0 +1,156 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Building2, Clock, CheckCircle } from 'lucide-react'
interface Props {
existing: { fundName: string; reason: string; createdAt: string } | null
managedFund: { name: string; slug: string } | null
}
export default function FundApplicationClient({ existing, managedFund }: Props) {
const router = useRouter()
const [fundName, setFundName] = useState('')
const [reason, setReason] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [withdrawing, setWithdrawing] = useState(false)
// Pending application
if (existing) {
async function withdraw() {
setWithdrawing(true)
await fetch('/api/fund-applications', { method: 'DELETE' })
setWithdrawing(false)
router.refresh()
}
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" />
<p className="font-medium">Application pending review</p>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-slate-500 text-xs uppercase tracking-wide">Fund Name</span>
<p className="text-white mt-0.5">{existing.fundName}</p>
</div>
<div>
<span className="text-slate-500 text-xs uppercase tracking-wide">Reason</span>
<p className="text-slate-300 mt-0.5 whitespace-pre-wrap">{existing.reason}</p>
</div>
<p className="text-xs text-slate-500">
Submitted {new Date(existing.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={withdraw}
disabled={withdrawing}
className="text-xs text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
>
{withdrawing ? 'Withdrawing…' : 'Withdraw application'}
</button>
</div>
</div>
)
}
// Submit form
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!fundName.trim() || !reason.trim()) return
setLoading(true)
setError('')
const res = await fetch('/api/fund-applications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fundName: fundName.trim(), reason: reason.trim() }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed to submit'); return }
router.refresh()
}
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" />
<span className="font-medium">New Fund Application</span>
</div>
{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>
)}
<div>
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">
Fund Name <span className="normal-case">(max 60 chars)</span>
</label>
<input
value={fundName}
onChange={(e) => setFundName(e.target.value)}
maxLength={60}
placeholder="TechAlpha Capital"
required
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">
Why do you want to run this fund?
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={5}
minLength={10}
maxLength={1000}
placeholder="Describe your strategy, what hashtags you plan to focus on, and why you'd be a good fund manager…"
required
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
/>
<p className="text-xs text-slate-600 mt-0.5 text-right">{reason.length}/1000</p>
</div>
<button
type="submit"
disabled={loading || !fundName.trim() || reason.trim().length < 10}
className="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
>
{loading ? 'Submitting…' : 'Submit Application'}
</button>
</form>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import FundApplicationClient from './FundApplicationClient'
export const dynamic = 'force-dynamic'
export default async function FundApplyPage() {
const session = await getServerSession(authOptions)
if (!session) redirect('/auth/signin?callbackUrl=/fund/apply')
const [application, managedFund] = await Promise.all([
prisma.fundApplication.findUnique({ where: { userId: session.user.id } }),
prisma.fundManager.findFirst({
where: { userId: session.user.id },
include: { fund: { select: { name: true, slug: true } } },
}),
])
return (
<div className="max-w-xl mx-auto space-y-6 py-8">
<div>
<h1 className="text-2xl font-bold">Apply for a Hedge Fund</h1>
<p className="text-slate-400 text-sm mt-1">
Propose a new fund. Admins will review your application and approve or deny it.
</p>
</div>
<FundApplicationClient
existing={application ? { fundName: application.fundName, reason: application.reason, createdAt: application.createdAt.toISOString() } : null}
managedFund={managedFund ? { name: managedFund.fund.name, slug: managedFund.fund.slug } : null}
/>
</div>
)
}
+68 -11
View File
@@ -2,6 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { formatCurrency, formatNumber } from '@/lib/utils' import { formatCurrency, formatNumber } from '@/lib/utils'
interface Props { interface Props {
@@ -11,18 +12,20 @@ interface Props {
shortPosition: { shares: number; avgBuyPrice: number } | null shortPosition: { shares: number; avgBuyPrice: number } | null
fundId?: string fundId?: string
fundName?: string fundName?: string
managedFunds?: { slug: string; name: string }[]
maxPositionShares: number maxPositionShares: number
maxPositionValue: number maxPositionValue: number
} }
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT' 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 router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG') const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('') const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showFundMenu, setShowFundMenu] = useState(false)
const sharesNum = parseFloat(shares) || 0 const sharesNum = parseFloat(shares) || 0
const cost = sharesNum * hashtag.currentPrice const cost = sharesNum * hashtag.currentPrice
@@ -65,13 +68,53 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
return ( return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5"> <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"> <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> <span className="text-base">🏦</span>
Trading as <span className="font-semibold">{fundName}</span> <span>Trading as <span className="font-semibold">{fundName}</span></span>
<span className="text-indigo-500 ml-auto">Fund mode</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>
)} )}
</div>
) : null}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2> <h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
@@ -85,7 +128,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
<button <button
key={t} key={t}
onClick={() => { setTab(t); setShares(''); setError('') }} 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 tab === t
? t.startsWith('BUY') ? t.startsWith('BUY')
? 'bg-emerald-600 text-white' ? 'bg-emerald-600 text-white'
@@ -93,7 +136,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
: 'text-slate-400 hover:text-slate-200' : '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> </button>
))} ))}
</div> </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="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface rounded-lg p-3"> <div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">LONG position</p> <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="font-medium">{formatNumber(longPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</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> <p className="text-slate-600">None</p>
)} )}
</div> </div>
<div className="bg-surface rounded-lg p-3"> <div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">SHORT position</p> <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="font-medium">{formatNumber(shortPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</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> <p className="text-slate-600">None</p>
)} )}
</div> </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_SHARES = parseInt(process.env.FUND_MAX_POSITION_SHARES ?? '1000', 10)
const FUND_MAX_POSITION_VALUE = parseInt(process.env.FUND_MAX_POSITION_VALUE ?? '10000', 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' export const dynamic = 'force-dynamic'
interface Props { interface Props {
@@ -25,6 +27,51 @@ interface Props {
searchParams: { fund?: string } 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) { export default async function HashtagPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '') const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
@@ -35,11 +82,11 @@ export default async function HashtagPage({ params, searchParams }: Props) {
where: { tag }, where: { tag },
include: { include: {
priceHistory: { priceHistory: {
orderBy: { recordedAt: 'asc' }, orderBy: { recordedAt: 'desc' },
take: 200, take: 192, // 192 updates = 2 days of 15-min intervals
}, },
_count: { _count: {
select: { positions: true }, select: { positions: { where: { shares: { gt: 0 } } } },
}, },
relatedFrom: { relatedFrom: {
orderBy: { coOccurrences: 'desc' }, 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 // Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) { if (!hashtag || !hashtag.isActive) {
return ( 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"> <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> <h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
<PriceChart <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} height={280}
/> />
</div> </div>
@@ -199,6 +256,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
fundName={fundContext?.name} fundName={fundContext?.name}
maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES} maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES}
maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE} 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"> <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'] }) const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
title: 'HashEx — The Hashtag Exchange', title: 'HashEx — The Hashtag Exchange',
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.', 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 { Trophy, TrendingUp, TrendingDown, Building2, Users } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh' import { AutoRefresh } from '@/components/AutoRefresh'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic' 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() { async function getLeaderboard() {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { isFund: false, isHidden: false }, where: { isFund: false, isHidden: false },
+9 -3
View File
@@ -102,11 +102,11 @@ export default async function HomePage() {
</h1> </h1>
<p className="text-slate-400 max-w-xl mx-auto"> <p className="text-slate-400 max-w-xl mx-auto">
Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon. Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon.
Research a tag to unlock it, then buy long or short.{' '} Research a tag to unlock it, then buy long or short.
<Link href="/about" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2"> </p>
<Link href="/about" className="inline-block mt-2 text-sm text-indigo-400 hover:text-indigo-300 underline underline-offset-2 whitespace-nowrap">
Learn more Learn more
</Link> </Link>
</p>
<div className="flex justify-center gap-4 mt-6"> <div className="flex justify-center gap-4 mt-6">
{session ? ( {session ? (
<> <>
@@ -183,6 +183,12 @@ export default async function HomePage() {
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-400" /> <TrendingUp className="h-5 w-5 text-indigo-400" />
Your top positions Your top positions
<Link
href="/positions"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{holdings.biggestGain && ( {holdings.biggestGain && (
+2 -2
View File
@@ -88,7 +88,7 @@ export default async function PositionsPage({
displayTag: true, displayTag: true,
currentPrice: true, currentPrice: true,
priceHistory: { priceHistory: {
orderBy: { recordedAt: 'asc' }, orderBy: { recordedAt: 'desc' },
take: 20, take: 20,
select: { price: true }, select: { price: true },
}, },
@@ -148,7 +148,7 @@ export default async function PositionsPage({
const costBasis = pos.avgBuyPrice * pos.shares const costBasis = pos.avgBuyPrice * pos.shares
const currentValue = pos.hashtag.currentPrice * pos.shares const currentValue = pos.hashtag.currentPrice * pos.shares
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0 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 } return { ...pos, pnl, costBasis, currentValue, pnlPct, sparkPrices }
}) })
+54 -32
View File
@@ -13,12 +13,34 @@ import CloseAccountForm from './CloseAccountForm'
import ResetAccountForm from './ResetAccountForm' import ResetAccountForm from './ResetAccountForm'
import { PriceChart } from '@/components/PriceChart' import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
interface Props { interface Props {
params: { username: string } 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) { export default async function ProfilePage({ params }: Props) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
const username = decodeURIComponent(params.username).toLowerCase() 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> <p className="text-sm text-slate-400">total portfolio value</p>
{lotteryCount > 0 && ( {lotteryCount > 0 && (
<p className="text-xs text-amber-400 mt-1"> <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> </p>
)} )}
</div> </div>
@@ -187,6 +209,37 @@ export default async function ProfilePage({ params }: Props) {
</div> </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 */} {/* Positions */}
{user.positions.length > 0 && ( {user.positions.length > 0 && (
<section> <section>
@@ -240,37 +293,6 @@ export default async function ProfilePage({ params }: Props) {
</section> </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 */} {/* Trade history */}
{user.trades.length > 0 && ( {user.trades.length > 0 && (
<section> <section>
+2 -2
View File
@@ -86,7 +86,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
take: 2, take: 2,
select: { price: true, postsPerHour: true }, 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, take: 2,
select: { price: true, postsPerHour: true }, select: { price: true, postsPerHour: true },
}, },
_count: { select: { positions: true } }, _count: { select: { positions: { where: { shares: { gt: 0 } } } } },
}, },
}) })
.then((rows) => .then((rows) =>
+33 -5
View File
@@ -15,15 +15,28 @@ interface PageProps {
export default async function GlobalTradesPage({ searchParams }: PageProps) { export default async function GlobalTradesPage({ searchParams }: PageProps) {
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10)) 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([ 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({ 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' }, orderBy: { createdAt: 'desc' },
take: PAGE_SIZE, take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE, skip: (page - 1) * PAGE_SIZE,
include: { include: {
hashtag: { select: { tag: true, displayTag: true } }, hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
user: { select: { username: true, displayUsername: true, isFund: 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 ${ className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') (t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
? 'bg-orange-500/15 text-orange-400' ? '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') : t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400' ? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-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> </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 <Link
href={`/hashtag/${t.hashtag!.tag}`} href={`/hashtag/${t.hashtag!.tag}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0" className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
> >
#{t.hashtag!.displayTag} #{t.hashtag!.displayTag}
</Link> </Link>
)}
<span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span> <span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span>
</div> </div>
{/* Secondary row: user · time (left) shares @ price (right) */} {/* Secondary row: user · time (left) shares @ price (right) */}
@@ -79,8 +107,8 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
</div> </div>
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span> <span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
</div> </div>
{/* PnL: sell and liquidation trades */} {/* 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 === '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 className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
)} )}
</div> </div>
+42 -26
View File
@@ -3,30 +3,34 @@
import Link from 'next/link' import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { normalizeTag } from '@/lib/utils' import { normalizeTag } from '@/lib/utils'
type Suggestion = { tag: string; displayTag: string; currentPrice: number } type Suggestion = { tag: string; displayTag: string; currentPrice: number }
export function Navbar() { function NavSearchInner() {
const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const fundSlug = searchParams.get('fund')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([]) const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function handleSearch(e: React.FormEvent) { function navigate(tag: string) {
e.preventDefault() const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
const tag = normalizeTag(query) router.push(url)
if (tag) {
router.push(`/hashtag/${tag}`)
setQuery('') setQuery('')
setSuggestions([]) setSuggestions([])
setShowSuggestions(false) setShowSuggestions(false)
} }
function handleSearch(e: React.FormEvent) {
e.preventDefault()
const tag = normalizeTag(query)
if (tag) navigate(tag)
} }
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) { function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -52,16 +56,6 @@ export function Navbar() {
} }
return ( 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"> <form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <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} onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} 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" 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 && ( {showSuggestions && suggestions.length > 0 && (
@@ -80,12 +74,7 @@ export function Navbar() {
<button <button
key={s.tag} key={s.tag}
type="button" type="button"
onMouseDown={() => { onMouseDown={() => navigate(s.tag)}
router.push(`/hashtag/${s.tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors" className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
> >
<span className="font-medium">#{s.displayTag}</span> <span className="font-medium">#{s.displayTag}</span>
@@ -96,6 +85,33 @@ export function Navbar() {
)} )}
</div> </div>
</form> </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 */} {/* Right section */}
<div className="flex items-center gap-3 shrink-0"> <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 * Returns posts-per-hour AND a sorted list of co-occurring tag names
* (lowercased, excluding the queried tag itself). * (lowercased, excluding the queried tag itself).
* *
* Strategy: * Pagination strategy:
* - Paginate until we have at least one post older than 1 hour (a complete picture), * - Keep fetching pages until >= 50% of posts in a page fall outside the 1-hour window,
* OR we exhaust the timeline, OR we hit MAX_PAGES_PER_HASHTAG. * OR the timeline is exhausted, OR MAX_PAGES_PER_HASHTAG is reached.
* - If the oldest fetched post is >= 1 hour old: postsPerHour = count of posts in the * - The 50% rule handles federated out-of-order posts gracefully: Mastodon timelines are
* last hour (direct measurement over a full window). * ordered by post ID (local receive time), not created_at. A remote post from hours or
* - If all fetched posts are within the last hour (hit page limit or timeline exhausted * even years ago can arrive late, get a fresh ID, and appear at the top of the stream.
* with a narrow window): extrapolate — postsPerHour = count / (coveredHours). * 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( export async function getPostsData(
tag: string, tag: string,
@@ -89,6 +99,7 @@ export async function getPostsData(
let allPosts: MastodonPost[] = [] let allPosts: MastodonPost[] = []
let maxId: string | undefined let maxId: string | undefined
let hitPageCap = false
for (let page = 0; page < maxPages; page++) { for (let page = 0; page < maxPages; page++) {
const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit) const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
@@ -99,28 +110,46 @@ export async function getPostsData(
// End of timeline or no more pages // End of timeline or no more pages
if (posts.length < postLimit || !nextMaxId) break if (posts.length < postLimit || !nextMaxId) break
// If the oldest post in this batch is already beyond 1 hour, we have a full window // Stop when >= 50% of this page's posts are outside the 1-hour window.
const oldestInBatch = Math.min(...posts.map((p) => new Date(p.created_at).getTime())) // A handful of old federated posts won't trigger this; once the majority of a page
if (oldestInBatch < cutoff) break // 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 maxId = nextMaxId
if (page === maxPages - 1) hitPageCap = true
} }
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false } 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 times = allPosts.map((p) => new Date(p.created_at).getTime())
const newestMs = Math.max(...times) const newestMs = times[0]
const oldestMs = Math.min(...times) const oldestMs = times[times.length - 1]
let postsPerHour: number let postsPerHour: number
if (oldestMs < cutoff) { if (oldestMs < cutoff) {
// We reached (or passed) the 1-hour horizon — count posts within the last hour directly // 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 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 { } else {
// All posts are within the last hour (burst scenario or very sparse tag). // Timeline exhausted — these are all the posts that exist within the last hour.
// Extrapolate from the covered span. Minimum 1-minute span to avoid divide-by-zero. // Use the raw count directly; extrapolating would inflate a sparse tag.
const coveredMs = Math.max(newestMs - oldestMs, 60_000) postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
postsPerHour = allPosts.length / (coveredMs / ONE_HOUR_MS)
} }
// Count co-occurring tags from the API tags object (authoritative for membership) // 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 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. * Formula: price = base * pph / (1 + k * pph)
* Examples: * where k is chosen so the curve hits $250 at 3 600 PPH.
* 1 post/hr → $0.25 *
* 10 posts/hr → $2.50 * Anchor points:
* 100 → $25.00 * 1 post/hr → ~$0.25
* 1000 → $250.00 * 10 posts/hr~$2.48
* 12 000 (viral #happynewyear) → $3 000 * 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 { export function calcPrice(postsPerHour: number): number {
if (postsPerHour <= 0) return 0.25 if (postsPerHour <= 1) return 0.25
return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100) 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' 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 { 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 = { export const config = {
matcher: [ matcher: [
'/profile/:path*',
'/positions', '/positions',
'/history', '/history',
'/admin/:path*', '/admin/:path*',
+41 -11
View File
@@ -144,25 +144,38 @@ const priceWorker = new Worker(
} }
const shouldDeactivate = ttlExpired && ownerCount === 0 const shouldDeactivate = ttlExpired && ownerCount === 0
await prisma.hashtag.update({ const floorPrice = calcPrice(0)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId }, where: { id: hashtagId },
data: { data: {
zeroCount: newZeroCount, zeroCount: newZeroCount,
isActive: shouldDeactivate ? false : hashtag.isActive, isActive: shouldDeactivate ? false : hashtag.isActive,
lastUpdated: new Date(), 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)' : ''}`) console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`)
return 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) { if (ttlExpired && ownerCount === 0) {
await prisma.hashtag.update({ const finalPrice = calcPrice(postsPerHour)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId }, where: { id: hashtagId },
data: { isActive: false, lastUpdated: new Date() }, data: { currentPrice: finalPrice, isActive: false, lastUpdated: new Date() },
}) }),
console.log(`[price] #${tag} deactivated — TTL expired, no holders`) prisma.priceHistory.create({
data: { hashtagId, price: finalPrice, postsPerHour },
}),
])
console.log(`[price] #${tag} deactivated — TTL expired, no holders (final price $${finalPrice.toFixed(2)})`)
return return
} }
@@ -234,7 +247,7 @@ const maintenanceWorker = new Worker(
console.log(`[maintenance] running daily maintenance (job ${job.id})`) console.log(`[maintenance] running daily maintenance (job ${job.id})`)
const MAX_RESEARCH_POINTS = 10 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) { for (const user of users) {
const points = dailyResearchPoints(user.balance) const points = dailyResearchPoints(user.balance)
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS) const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
@@ -291,6 +304,23 @@ const maintenanceWorker = new Worker(
console.log( console.log(
`[maintenance] pruned snapshots — fund NAV: ${deletedFundNav.count} rows, user portfolio: ${deletedUserPortfolio.count} rows (>7d)`, `[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() }, { 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( await maintenanceQueue.add(
'daily-maintenance', '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( await fundNavSnapshotQueue.add(
'fund-nav-snapshot', 'fund-nav-snapshot',
{}, {},