Compare commits

...

84 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
ThaMunsta cd8e23747b feat: add About page with detailed game mechanics and links
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m51s
2026-03-20 20:49:18 -04:00
ThaMunsta 5020f38090 fix: handle deleted funds in trade and profile displays
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-20 20:19:49 -04:00
ThaMunsta ef01ea9a3a feat: include fund information in trade display and enhance fund trade styling
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-20 19:55:37 -04:00
ThaMunsta 840345d093 feat: enhance fund investment and redemption logging, and improve positions display
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-20 19:47:59 -04:00
ThaMunsta 9dd9cf5ed9 fix: add rounding function to pricing calculations in worker
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-20 16:41:03 -04:00
ThaMunsta 14d79acc63 fix: implement rounding for monetary values across fund and user transactions
Build Images and Deploy / Update-PROD-Stack (push) Failing after 33s
2026-03-20 16:18:06 -04:00
ThaMunsta 1d0b160ba8 fix: ensure non-negative payouts in fund investment calculations
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-20 15:33:37 -04:00
ThaMunsta c5076e330c fix: update fund investment logic to decrement sharesOutstanding and withdraw cash from fund
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-20 15:28:59 -04:00
ThaMunsta 898f99049d fix: increase precision for shares display and input step in InvestPanel
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
2026-03-20 15:17:46 -04:00
ThaMunsta 7367e4d7c6 fix: enhance fund investment value calculation and adjust portfolio total value logic 2026-03-20 15:16:22 -04:00
ThaMunsta 5974e8fd87 fix: improve portfolio value calculation and round shares to six decimal places
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-20 15:06:57 -04:00
ThaMunsta ba8fa8e253 revert c1bcac8a30
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
revert fix: implement fund dissolution for insolvent funds after SELL_SHORT trades
hate that
2026-03-20 18:44:55 +00:00
ThaMunsta c1bcac8a30 fix: implement fund dissolution for insolvent funds after SELL_SHORT trades
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-20 14:37:17 -04:00
ThaMunsta 682c76128c fix: update balance display to indicate negative values in TradePanel, ResetAccountForm, ProfilePage, and Navbar
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m28s
2026-03-20 14:29:20 -04:00
ThaMunsta f0cf1f6461 fix order of records for reasons
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-20 14:11:17 -04:00
ThaMunsta 621d3a9120 try to fix shorts/bankruptcy
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-20 13:26:22 -04:00
ThaMunsta c28720be5a fix: refactor user creation to store user in a variable before trade creation
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
2026-03-20 12:49:12 -04:00
ThaMunsta ea1dca974c account reset options
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-20 12:42:20 -04:00
ThaMunsta bf14b039c6 allow an account to reset 2026-03-20 12:24:44 -04:00
ThaMunsta 8783fbf459 testing something
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-20 12:06:21 -04:00
ThaMunsta a6cf9f8db7 fix: update Mastodon data fetching to include hasAnyPosts flag
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m27s
2026-03-20 11:46:45 -04:00
ThaMunsta eaff278e3d mobile fix 2026-03-20 11:17:30 -04:00
ThaMunsta ee0eb47a4b fix: update grid layout in PositionsPage for improved responsiveness
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 23:30:48 -04:00
ThaMunsta 8cf116de2d fix: update grid layout for PositionsPage to improve responsiveness
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m25s
2026-03-19 23:19:18 -04:00
ThaMunsta 84aa69884d revert 0e7c9dd890
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
revert experimental smaller docker image
2026-03-20 02:23:15 +00:00
ThaMunsta 0e7c9dd890 experimental smaller docker image
Build Images and Deploy / Update-PROD-Stack (push) Failing after 41s
2026-03-19 22:20:22 -04:00
ThaMunsta a682ac9b22 fix: correct comment syntax in StocksPage component
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-19 22:17:27 -04:00
ThaMunsta 125e62c19c fix: correct AutoRefresh component rendering in HomePage
Build Images and Deploy / Update-PROD-Stack (push) Failing after 32s
2026-03-19 22:15:44 -04:00
ThaMunsta c5e5f9eaf6 feat: add max position limits to TradePanel and page components for enhanced trading controls
Build Images and Deploy / Update-PROD-Stack (push) Failing after 31s
2026-03-19 22:13:30 -04:00
ThaMunsta 005b4543f6 feat: add AutoRefresh component for automatic data refreshing on pages
Build Images and Deploy / Update-PROD-Stack (push) Failing after 33s
2026-03-19 22:10:50 -04:00
ThaMunsta d0dc52f82b feat: add position limits and enhance currency formatting
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 22:05:15 -04:00
ThaMunsta 82b1953c8b feat: implement sortable positions table with dynamic sorting and display enhancements
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-19 21:04:13 -04:00
ThaMunsta 5c853bd1ee feat: update environment variables for price updates and zombie settings
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m26s
2026-03-19 19:28:55 -04:00
ThaMunsta 1cf7892c8b feat: enhance positions table layout for better responsiveness
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 19:06:33 -04:00
ThaMunsta e1e6790628 feat: add zombie warning for auto-liquidation risk based on inactivity 2026-03-19 19:05:57 -04:00
ThaMunsta d55e3dfef2 move warning banner
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-19 18:56:34 -04:00
ThaMunsta c3b0055572 zombie alerts
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 18:38:15 -04:00
ThaMunsta 54ecf35cf3 feat: implement NAV calculation and payout for fund investments on user deletion
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 18:11:12 -04:00
ThaMunsta f3f3591e34 fix: add sharesOutstanding field to hedge fund creation
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 16:46:14 -04:00
ThaMunsta 422d85e97e fix: update fund-nav-snapshot job to run every 15 minutes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 16:26:37 -04:00
ThaMunsta a0695fd11e feat: add account deletion functionality for users and admins 2026-03-19 16:23:15 -04:00
ThaMunsta 873b86f85e admin QOL features
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m24s
2026-03-19 16:13:53 -04:00
ThaMunsta aa7a80c3e7 history for users too
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 16:07:47 -04:00
ThaMunsta 9a87c0c7c5 add some fund graph history for transparency
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 15:59:47 -04:00
ThaMunsta 5e9421801e hotfix build errors
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m27s
2026-03-19 15:03:26 -04:00
ThaMunsta 40a1034000 refactor: enhance stock change display and improve hashtag card status logic and timeline fetch logic
Build Images and Deploy / Update-PROD-Stack (push) Failing after 32s
2026-03-19 15:00:46 -04:00
ThaMunsta fdb234641f design changes for profile activity
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 02:04:49 -04:00
ThaMunsta 249909f3de lighter change - luck totals
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-19 02:00:28 -04:00
ThaMunsta 6bfbfcc8a0 liquidation rules hahaha
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m25s
2026-03-19 01:58:45 -04:00
ThaMunsta da568646e2 more mobile fixes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 01:41:31 -04:00
ThaMunsta 1475c17a9c mobile optimizations 2026-03-19 01:36:39 -04:00
58 changed files with 3641 additions and 435 deletions
+2
View File
@@ -24,6 +24,8 @@ MAX_PAGES_PER_HASHTAG=5
# Price history retention: days to keep for active hashtags, hours for inactive ones # Price history retention: days to keep for active hashtags, hours for inactive ones
PRICE_HISTORY_ACTIVE_DAYS=7 PRICE_HISTORY_ACTIVE_DAYS=7
PRICE_HISTORY_INACTIVE_HOURS=24 PRICE_HISTORY_INACTIVE_HOURS=24
# Consecutive zero-post updates before all positions are force-closed and the hashtag retired
ZOMBIE_ZERO_COUNT=1000
# Initial admin user — only used by `npm run db:seed`, not the running app. # Initial admin user — only used by `npm run db:seed`, not the running app.
# Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed # Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed
+16 -12
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).
@@ -359,9 +364,8 @@ The items below are planned improvements roughly ordered by user value. They are
### Other Ideas / Nice-to-Haves ### Other Ideas / Nice-to-Haves
- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades.
- **Email integration**: SMTP-based password reset and optional trade confirmation emails. - **Email integration**: SMTP-based password reset and optional trade confirmation emails.
- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances. - **Multi-instance support**: fallback to another instance if the primary instance is unavailable or throttles API calls.
- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX. - **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level. - **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
- **Dark/light theme toggle**: currently dark-only. - **Dark/light theme toggle**: currently dark-only.
+51 -6
View File
@@ -22,12 +22,14 @@ model User {
isFund Boolean @default(false) isFund Boolean @default(false)
isHidden Boolean @default(false) // hidden from leaderboards and public listings isHidden Boolean @default(false) // hidden from leaderboards and public listings
positions Position[] positions Position[]
trades Trade[] trades Trade[]
passwordResets PasswordReset[] passwordResets PasswordReset[]
managedFunds FundManager[] managedFunds FundManager[]
fund HedgeFund? fund HedgeFund?
fundInvestments FundInvestment[] fundInvestments FundInvestment[]
portfolioHistory UserPortfolioHistory[]
fundApplication FundApplication?
} }
model HedgeFund { model HedgeFund {
@@ -41,6 +43,8 @@ model HedgeFund {
managers FundManager[] managers FundManager[]
investments FundInvestment[] investments FundInvestment[]
navHistory FundNavHistory[]
trades Trade[]
@@index([slug]) @@index([slug])
} }
@@ -131,6 +135,28 @@ model PriceHistory {
@@index([hashtagId, recordedAt]) @@index([hashtagId, recordedAt])
} }
model FundNavHistory {
id String @id @default(cuid())
fundId String
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
nav Float
totalValue Float
recordedAt DateTime @default(now())
@@index([fundId, recordedAt])
}
model UserPortfolioHistory {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
totalValue Float
portfolioValue Float
recordedAt DateTime @default(now())
@@index([userId, recordedAt])
}
model Position { model Position {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@@ -154,6 +180,8 @@ model Trade {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashtagId String? hashtagId String?
hashtag Hashtag? @relation(fields: [hashtagId], references: [id]) hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
fundId String?
fund HedgeFund? @relation(fields: [fundId], references: [id], onDelete: SetNull)
type TradeType type TradeType
shares Float shares Float
price Float // price per share at time of trade (or win amount for LOTTERY_WIN) price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
@@ -163,6 +191,7 @@ model Trade {
@@index([userId]) @@index([userId])
@@index([hashtagId]) @@index([hashtagId])
@@index([fundId])
@@index([createdAt]) @@index([createdAt])
} }
@@ -177,4 +206,20 @@ enum TradeType {
BUY_SHORT BUY_SHORT
SELL_SHORT SELL_SHORT
LOTTERY_WIN LOTTERY_WIN
LIQUIDATE_LONG
LIQUIDATE_SHORT
DONATION // keepHistory reset: user was in the green — donated their portfolio
BANKRUPTCY // keepHistory reset: user was in the red — debts cleared
ACCOUNT_OPEN // keepHistory reset: new $2000 account opening entry
FUND_INVEST // invested cash into 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())
} }
+14 -1
View File
@@ -13,6 +13,14 @@ services:
REDIS_URL: "${REDIS_URL}" REDIS_URL: "${REDIS_URL}"
MASTODON_INSTANCE: "${MASTODON_INSTANCE}" MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}" MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-15}"
ZOMBIE_ZERO_COUNT: "${ZOMBIE_ZERO_COUNT:-1000}"
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
MAX_POSITION_SHARES: "${MAX_POSITION_SHARES:-100}"
MAX_POSITION_VALUE: "${MAX_POSITION_VALUE:-1000}"
FUND_MAX_POSITION_SHARES: "${FUND_MAX_POSITION_SHARES:-1000}"
FUND_MAX_POSITION_VALUE: "${FUND_MAX_POSITION_VALUE:-10000}"
TZ: "America/Toronto"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -29,9 +37,14 @@ services:
MASTODON_INSTANCE: "${MASTODON_INSTANCE}" MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}" MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}" WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}"
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}" PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-15}"
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}" HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}" MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}"
ZOMBIE_ZERO_COUNT: "${ZOMBIE_ZERO_COUNT:-1000}"
MASTODON_POST_LIMIT: "${MASTODON_POST_LIMIT:-20}"
PRICE_HISTORY_ACTIVE_DAYS: "${PRICE_HISTORY_ACTIVE_DAYS:-7}"
PRICE_HISTORY_INACTIVE_HOURS: "${PRICE_HISTORY_INACTIVE_HOURS:-24}"
TZ: "America/Toronto"
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
+232
View File
@@ -0,0 +1,232 @@
import Link from 'next/link'
import { BookOpen, TrendingUp, TrendingDown, Coins, Shuffle, RotateCcw, Building2, ExternalLink, Github } from 'lucide-react'
export const metadata = {
title: 'About — HashEx',
description: 'How HashEx works: rules, features, and quirks of the hashtag stock market.',
}
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
return (
<section className="space-y-3">
<div className="flex items-center gap-2 border-b border-surface-border pb-2">
<Icon className="h-5 w-5 text-indigo-400 shrink-0" />
<h2 className="text-lg font-semibold">{title}</h2>
</div>
{children}
</section>
)
}
function Rule({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-3 text-sm">
<span className="text-indigo-400 font-medium shrink-0 w-32">{label}</span>
<span className="text-slate-300">{children}</span>
</div>
)
}
export default function AboutPage() {
return (
<div className="max-w-2xl mx-auto space-y-10 py-4">
{/* Header */}
<div>
<div className="flex items-center gap-3 mb-2">
<BookOpen className="h-7 w-7 text-indigo-400" />
<h1 className="text-3xl font-bold">About HashEx</h1>
</div>
<p className="text-slate-400">
HashEx is a stock-market simulation game where the &quot;stocks&quot; are Mastodon hashtags.
Prices update automatically based on real post activity. Start with{' '}
<span className="text-white font-medium">$2,000</span> and see how much you can grow it.
</p>
</div>
{/* Getting started */}
<Section title="Getting Started" icon={TrendingUp}>
<div className="space-y-2 text-sm text-slate-300">
<p>
Every new account starts with <span className="text-white font-medium">$2,000</span> in play money and{' '}
<span className="text-white font-medium">1 research point</span>.
</p>
<p>
Use research points to unlock hashtags. Each point lets you search for a tag on Mastodon if it has
activity, it gets added to the exchange with a live price. You earn more points each day based on your
account balance.
</p>
<div className="bg-surface-card border border-surface-border rounded-lg p-3 space-y-1 mt-2">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Daily research points</p>
<div className="grid grid-cols-2 gap-x-4 text-xs">
<span className="text-slate-400">Balance under $10k</span><span>1 pt / day</span>
<span className="text-slate-400">$10k+</span><span>2 pts / day</span>
<span className="text-slate-400">$100k+</span><span>3 pts / day</span>
<span className="text-slate-400">$1M+</span><span>5 pts / day</span>
</div>
</div>
</div>
</Section>
{/* Pricing */}
<Section title="How Prices Work" icon={Coins}>
<div className="space-y-2 text-sm text-slate-300">
<p>
Every hashtag has a price derived from its posts-per-hour rate on Mastodon using a
{' '}<span className="text-white font-medium">saturating curve</span> prices rise quickly at
low activity and flatten at high activity so a single viral tag can&apos;t dominate the market.
</p>
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300 text-xs">
price = (0.25 × pph) / (1 + k × pph) &nbsp;·&nbsp; floor $0.25
</div>
<div className="grid grid-cols-2 gap-x-4 text-xs mt-1">
<span className="text-slate-400">1 post / hr</span><span>~$0.25</span>
<span className="text-slate-400">10 posts / hr</span><span>~$2.48</span>
<span className="text-slate-400">100 posts / hr</span><span>~$23</span>
<span className="text-slate-400">1,000 posts / hr</span><span>~$145</span>
<span className="text-slate-400">3,600 posts / hr (1/sec)</span><span>~$250</span>
<span className="text-slate-400"> (asymptote)</span><span>~$346</span>
</div>
<p className="text-xs text-slate-400">
Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be
automatically <span className="text-orange-400">deactivated</span> you&apos;ll get a warning on the
home page if any of your positions are at risk. Research it again to reactivate it.
</p>
</div>
</Section>
{/* Trade types */}
<Section title="Trade Types" icon={TrendingUp}>
<div className="space-y-3">
<Rule label="Buy Long">
Bet the price goes <span className="text-emerald-400">up</span>. You buy shares and profit when the
price rises above your average buy price.
</Rule>
<Rule label="Sell Long">
Close or reduce a long position. Profit = (current price avg buy price) × shares.
</Rule>
<Rule label="Buy Short">
Bet the price goes <span className="text-red-400">down</span>. You put up collateral and profit when
the price falls below your entry.
</Rule>
<Rule label="Sell Short">
Close a short. Profit = (avg entry current price) × shares. If the price rose above your entry you
take a loss and your balance <span className="text-red-400">can go negative</span>.
</Rule>
</div>
<p className="text-xs text-slate-500 mt-2">
All trades are validated server-side. You cannot buy more than your balance, sell more shares than you hold,
or trade a hashtag you haven&apos;t researched.
</p>
</Section>
{/* Short selling specifics */}
<Section title="Short Selling — Quirks & Risks" icon={TrendingDown}>
<div className="space-y-2 text-sm text-slate-300">
<p>
Shorts use a <span className="text-white font-medium">collateral model</span>. When you buy short, the
cost is <code className="text-xs bg-surface-card px-1 py-0.5 rounded">price × shares</code>. When you
close, you receive back <code className="text-xs bg-surface-card px-1 py-0.5 rounded">(2 × entry current) × shares</code>.
</p>
<p>
This means losses are <span className="text-red-400">uncapped</span>. If the price doubles, your
payout is zero. If it triples, your balance goes negative.
</p>
<p>
A <span className="text-red-400">negative balance</span> isn&apos;t game-over you can still trade, but
your total portfolio value will show in red. You can reset your account at any time from your profile page.
</p>
</div>
</Section>
{/* Hedge Funds */}
<Section title="Hedge Funds" icon={Building2}>
<div className="space-y-2 text-sm text-slate-300">
<p>
Admins can create <span className="text-white font-medium">Hedge Funds</span> shared pools of capital
that multiple players can manage together.
</p>
<p>
As a fund manager you trade on behalf of the fund by appending{' '}
<code className="text-xs bg-surface-card px-1 py-0.5 rounded">?fund=[slug]</code> to any hashtag page
(there are quick links on the fund page). A banner confirms you&apos;re in Fund Mode.
</p>
<p>
Outside investors can buy and sell <span className="text-white font-medium">fund shares</span> from the
fund&apos;s page. The NAV (net asset value) per share is calculated live from the fund&apos;s total
portfolio. Fund investments show up in your Holdings and Trade History.
</p>
<p className="text-slate-500 text-xs">
Fund shares are stored to 6 decimal places. Fund accounts cannot sign in directly and do not earn
research points or play the lottery.
</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>
</Section>
{/* Account reset */}
<Section title="Account Reset" icon={RotateCcw}>
<div className="space-y-2 text-sm text-slate-300">
<p>
You can reset your account from your profile page at any time. All positions are closed and your
balance returns to $2,000.
</p>
<Rule label="Keep history">
Your trade log is preserved. A{' '}
<span className="text-purple-400">Donation</span> entry is recorded if you were in profit, or a{' '}
<span className="text-red-400">Bankruptcy</span> if you were in the red, followed by an{' '}
<span className="text-emerald-400">Account Open</span>.
</Rule>
<Rule label="Erase history">
All trade records are deleted along with the reset.
</Rule>
</div>
</Section>
{/* Lucky Dip */}
<Section title="Lucky Dip" icon={Shuffle}>
<p className="text-sm text-slate-300">
Once per day you can open the Lucky Dip lottery. Pick a box most are empty, but a few hold cash prizes.
Winnings are added directly to your balance. So head over and check out our
<Link href="/lucky-dip" className="text-indigo-400 hover:text-indigo-300 underline underline-offset-2">
Lucky Dip page</Link> now or you can always find the link on our home page.
</p>
</Section>
{/* Links */}
<section className="bg-surface-card border border-surface-border rounded-xl p-5 space-y-3">
<h2 className="text-sm font-semibold text-slate-300 uppercase tracking-wider">Links</h2>
<div className="space-y-2">
<a
href="https://mastodon.nervesocket.com/@ThaMunsta"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
>
<ExternalLink className="h-4 w-4 shrink-0" />
@ThaMunsta on Mastodon questions, feedback, bug reports
</a>
<a
href="https://git.dev.nervesocket.com/ThaMunsta/hashex"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
>
<Github className="h-4 w-4 shrink-0" />
Source code contribute or run your own instance
</a>
</div>
</section>
<div className="text-center">
<Link href="/" className="text-sm text-indigo-400 hover:text-indigo-300">
Back to the exchange
</Link>
</div>
</div>
)
}
@@ -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>
)
}
+47 -12
View File
@@ -1,24 +1,59 @@
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([
orderBy: { createdAt: 'asc' }, prisma.hedgeFund.findMany({
include: { orderBy: { createdAt: 'asc' },
user: { select: { balance: true } }, include: {
managers: { user: { select: { balance: true } },
include: { user: { select: { id: true, username: true, displayUsername: true } } }, managers: {
orderBy: { addedAt: 'asc' }, include: { user: { select: { id: true, username: true, displayUsername: true } } },
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-4"> <div className="space-y-8">
<h2 className="text-lg font-semibold">Hedge Funds</h2> <div className="space-y-4">
<AdminFundActions funds={funds} /> <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">
<h2 className="text-lg font-semibold">Hedge Funds</h2>
<AdminFundActions funds={funds} />
</div>
</div> </div>
) )
} }
+36 -19
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) => {
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm"> const isLottery = t.type === 'LOTTERY_WIN'
<div className="flex items-center gap-2"> const isBuy = t.type.startsWith('BUY')
<span const isSell = t.type === 'SELL_LONG' || t.type === 'SELL_SHORT'
className={`text-xs px-1.5 py-0.5 rounded ${ const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
t.type === 'LOTTERY_WIN' const isSystem = t.type === 'ACCOUNT_OPEN' || t.type === 'DONATION' || t.type === 'BANKRUPTCY'
? 'bg-amber-500/15 text-amber-400' const badgeClass = isLottery
: t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400' ? 'bg-amber-500/15 text-amber-400'
}`} : isFundTrade
> ? 'bg-indigo-500/15 text-indigo-400'
{t.type.replace(/_/g, ' ')} : isSystem
</span> ? 'bg-slate-500/15 text-slate-400'
<span className="text-slate-300">{t.user.username}</span> : isBuy
<span className="text-slate-500"> ? 'bg-emerald-500/15 text-emerald-400'
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'} : isSell
</span> ? 'bg-red-500/15 text-red-400'
: 'bg-slate-500/15 text-slate-400'
const label = t.hashtag
? `#${t.hashtag.displayTag}`
: isLottery
? 'Lucky Dip'
: isFundTrade
? 'Fund'
: null
return (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${badgeClass}`}>
{t.type.replace(/_/g, ' ')}
</span>
<span className="text-slate-300">{t.user.username}</span>
{label && <span className="text-slate-500">{label}</span>}
</div>
<span>{formatCurrency(t.total)}</span>
</div> </div>
<span>{formatCurrency(t.total)}</span> )
</div> })}
))}
</div> </div>
</div> </div>
</div> </div>
+15 -3
View File
@@ -1,6 +1,7 @@
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue' import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import RetryFailedButton from '@/components/admin/RetryFailedButton' import RetryFailedButton from '@/components/admin/RetryFailedButton'
import TriggerJobButton from '@/components/admin/TriggerJobButton'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -69,13 +70,21 @@ async function getQueueSummary(queue: typeof priceUpdateQueue): Promise<QueueSum
} }
export default async function AdminQueuePage() { export default async function AdminQueuePage() {
const [priceSummary, maintenanceSummary, schedulerSummary] = await Promise.all([ const [priceSummary, maintenanceSummary, schedulerSummary, fundNavSummary] = await Promise.all([
getQueueSummary(priceUpdateQueue), getQueueSummary(priceUpdateQueue),
getQueueSummary(maintenanceQueue), getQueueSummary(maintenanceQueue),
getQueueSummary(schedulerQueue), getQueueSummary(schedulerQueue),
getQueueSummary(fundNavSnapshotQueue),
]) ])
const queues = [priceSummary, maintenanceSummary, schedulerSummary] const queues = [priceSummary, maintenanceSummary, schedulerSummary, fundNavSummary]
// Queues that support a manual trigger, and the label to show
const triggerLabels: Record<string, string> = {
'hashex-scheduler': 'Trigger sweep',
'hashex-maintenance': 'Run maintenance',
'hashex-fund-nav-snapshot': 'Snapshot NAVs',
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -90,6 +99,9 @@ export default async function AdminQueuePage() {
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-surface-border">
<h3 className="font-medium text-sm">{q.name}</h3> <h3 className="font-medium text-sm">{q.name}</h3>
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
{triggerLabels[q.name] && (
<TriggerJobButton queueName={q.name} label={triggerLabels[q.name]} />
)}
<RetryFailedButton queueName={q.name} count={q.failed} /> <RetryFailedButton queueName={q.name} count={q.failed} />
<Badge label="waiting" count={q.waiting} color="slate" /> <Badge label="waiting" count={q.waiting} color="slate" />
<Badge label="active" count={q.active} color="indigo" /> <Badge label="active" count={q.active} color="indigo" />
+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>
+128 -1
View File
@@ -24,6 +24,12 @@ export function AdminUserActions({ user }: { user: UserData }) {
const [resetUrl, setResetUrl] = useState<string | null>(null) const [resetUrl, setResetUrl] = useState<string | null>(null)
const [lotteryReset, setLotteryReset] = useState(false) const [lotteryReset, setLotteryReset] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteError, setDeleteError] = useState('')
const [accountResetConfirm, setAccountResetConfirm] = useState('')
const [accountResetError, setAccountResetError] = useState('')
const [accountResetDone, setAccountResetDone] = useState(false)
const [resetKeepHistory, setResetKeepHistory] = useState(false)
async function handleSave() { async function handleSave() {
setLoading(true) setLoading(true)
@@ -80,10 +86,51 @@ export function AdminUserActions({ user }: { user: UserData }) {
} }
} }
async function handleDelete() {
if (deleteConfirm !== user.username) {
setDeleteError('Username does not match.')
return
}
setLoading(true)
setDeleteError('')
const res = await fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' })
const data = await res.json()
setLoading(false)
if (!res.ok) {
setDeleteError(data.error ?? 'Delete failed.')
} else {
setOpen(false)
router.refresh()
}
}
async function handleAccountReset() {
if (accountResetConfirm !== user.username) {
setAccountResetError('Username does not match.')
return
}
setLoading(true)
setAccountResetError('')
const res = await fetch(`/api/admin/users/${user.id}/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepHistory: resetKeepHistory }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setAccountResetError(data.error ?? 'Reset failed.')
} else {
setAccountResetDone(true)
setAccountResetConfirm('')
router.refresh()
}
}
return ( return (
<> <>
<button <button
onClick={() => { setOpen(true); setResetUrl(null); setError('') }} onClick={() => { setOpen(true); setResetUrl(null); setError(''); setDeleteConfirm(''); setDeleteError(''); setAccountResetConfirm(''); setAccountResetError(''); setAccountResetDone(false); setResetKeepHistory(false) }}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
> >
Edit Edit
@@ -199,6 +246,86 @@ export function AdminUserActions({ user }: { user: UserData }) {
)} )}
</div> </div>
{/* Account reset */}
<div className="border-t border-amber-500/20 pt-4">
<p className="text-sm text-amber-400 mb-1 font-medium">Reset account</p>
<p className="text-xs text-slate-500 mb-2">
{resetKeepHistory
? 'Keeps trade history and adds DONATION/BANKRUPTCY + ACCOUNT OPEN bookmark entries. Balance resets to $2,000.'
: 'Permanently erases all trade history, positions, and fund investments, then resets the balance to $2,000.'}
</p>
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-xs text-slate-400">Keep trade history</p>
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
</div>
<button
type="button"
onClick={() => setResetKeepHistory((v) => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
resetKeepHistory ? 'bg-amber-500' : 'bg-slate-600'
}`}
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
resetKeepHistory ? 'translate-x-5' : 'translate-x-1'
}`} />
</button>
</div>
{accountResetDone ? (
<p className="text-xs text-emerald-400"> Account has been reset.</p>
) : (
<div className="space-y-2">
<input
type="text"
value={accountResetConfirm}
onChange={(e) => setAccountResetConfirm(e.target.value)}
placeholder={`Type "${user.username}" to confirm`}
autoComplete="off"
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
/>
{accountResetError && (
<p className="text-red-400 text-xs">{accountResetError}</p>
)}
<button
onClick={handleAccountReset}
disabled={loading || accountResetConfirm !== user.username}
className="text-sm bg-amber-700/30 hover:bg-amber-700/50 text-amber-400 border border-amber-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Resetting…' : 'Reset account'}
</button>
</div>
)}
</div>
{/* Danger zone — delete user */}
<div className="border-t border-red-500/20 pt-4">
<p className="text-sm text-red-400 mb-1 font-medium">Danger zone</p>
<p className="text-xs text-slate-500 mb-3">
Permanently deletes this account, all trades, positions, and history.
Fund investments will be forfeited to their respective funds.
</p>
<div className="space-y-2">
<input
type="text"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder={`Type "${user.username}" to confirm`}
autoComplete="off"
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
/>
{deleteError && (
<p className="text-red-400 text-xs">{deleteError}</p>
)}
<button
onClick={handleDelete}
disabled={loading || deleteConfirm !== user.username}
className="text-sm bg-red-700/30 hover:bg-red-700/50 text-red-400 border border-red-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Deleting…' : 'Delete account'}
</button>
</div>
</div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<button <button
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
@@ -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 })
}
+38 -4
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' import { z } from 'zod'
import { calcFundNav, round2 } from '@/lib/pricing'
const patchSchema = z.object({ const patchSchema = z.object({
addManagerUsername: z.string().optional(), addManagerUsername: z.string().optional(),
@@ -57,7 +58,7 @@ export async function PATCH(
} }
if (typeof balance === 'number') { if (typeof balance === 'number') {
await prisma.user.update({ where: { id: fund.userId }, data: { balance } }) await prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(balance) } })
} }
const updated = await prisma.hedgeFund.findUnique({ const updated = await prisma.hedgeFund.findUnique({
@@ -87,11 +88,44 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
const fund = await prisma.hedgeFund.findUnique({ where: { id: params.fundId } }) const fund = await prisma.hedgeFund.findUnique({
where: { id: params.fundId },
select: {
id: true,
userId: true,
sharesOutstanding: true,
investments: { select: { userId: true, shares: true } },
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
},
},
},
},
})
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 }) if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
// Must delete fund first (it holds the FK to User), then delete the shadow user // Compute mark-to-market NAV so investors are paid their fair share
// (which cascades positions and trades). const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
// Pay out each investor at current NAV before wiping records
for (const inv of fund.investments) {
const payout = Math.max(0, round2(inv.shares * nav))
if (payout > 0) {
await prisma.user.update({ where: { id: inv.userId }, data: { balance: { increment: payout } } })
}
}
// Delete fund first (FK constraint), then shadow user (cascades positions/trades)
await prisma.hedgeFund.delete({ where: { id: fund.id } }) await prisma.hedgeFund.delete({ where: { id: fund.id } })
await prisma.user.delete({ where: { id: fund.userId } }) await prisma.user.delete({ where: { id: fund.userId } })
+2 -1
View File
@@ -61,12 +61,13 @@ 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,
}, },
}) })
return tx.hedgeFund.create({ return tx.hedgeFund.create({
data: { name, slug, userId: shadowUser.id }, data: { name, slug, userId: shadowUser.id, sharesOutstanding: initialBalance },
include: { user: { select: { balance: true } }, managers: true }, include: { user: { select: { balance: true } }, managers: true },
}) })
}) })
+16 -1
View File
@@ -1,13 +1,21 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue' import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
import { Queue } from 'bullmq' import { Queue } from 'bullmq'
const QUEUES: Record<string, Queue> = { const QUEUES: Record<string, Queue> = {
'hashex-price-updates': priceUpdateQueue, 'hashex-price-updates': priceUpdateQueue,
'hashex-maintenance': maintenanceQueue, 'hashex-maintenance': maintenanceQueue,
'hashex-scheduler': schedulerQueue, 'hashex-scheduler': schedulerQueue,
'hashex-fund-nav-snapshot': fundNavSnapshotQueue,
}
// Job name to add when manually triggering each queue
const TRIGGER_JOB: Record<string, string> = {
'hashex-scheduler': 'trigger-sweep',
'hashex-maintenance': 'daily-maintenance',
'hashex-fund-nav-snapshot': 'fund-nav-snapshot',
} }
export async function POST( export async function POST(
@@ -36,5 +44,12 @@ export async function POST(
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} }
if (action === 'trigger') {
const jobName = TRIGGER_JOB[params.name]
if (!jobName) return NextResponse.json({ error: 'Queue does not support manual trigger' }, { status: 400 })
await queue.add(jobName, {}, { jobId: `manual-${Date.now()}` })
return NextResponse.json({ ok: true })
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
} }
@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { calcFundNav, round2 } from '@/lib/pricing'
const STARTING_BALANCE = 2000
/**
* POST /api/admin/users/[userId]/reset
* Body: { keepHistory?: boolean }
*
* Admin-only. Resets a user's account. See /api/user/me/reset for full docs.
*/
export async function POST(
req: NextRequest,
{ params }: { params: { userId: string } },
) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json().catch(() => ({}))
const keepHistory = body.keepHistory === true
const user = await prisma.user.findUnique({
where: { id: params.userId },
select: {
balance: true,
isFund: true,
fundInvestments: {
where: { shares: { gt: 0 } },
select: {
fundId: true,
shares: true,
fund: {
select: {
userId: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
avgBuyPrice: true,
positionType: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
},
},
},
},
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
avgBuyPrice: true,
positionType: true,
hashtag: { select: { currentPrice: true } },
},
},
},
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
if (user.isFund) {
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
}
const portfolioValue = user.positions.reduce((sum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const fundInvestmentValue = user.fundInvestments.reduce((sum, inv) => {
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return psum + val
}, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
return sum + Math.max(0, inv.shares * nav)
}, 0)
const totalValue = user.balance + portfolioValue + fundInvestmentValue
// Forfeit all fund investments — decrement sharesOutstanding and withdraw cash from fund
const fundUpdates = user.fundInvestments
.filter((inv) => inv.shares > 0)
.flatMap((inv) => {
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return psum + val
}, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
const payout = Math.max(0, round2(inv.shares * nav))
return [
prisma.hedgeFund.update({
where: { id: inv.fundId },
data: { sharesOutstanding: { decrement: inv.shares } },
}),
prisma.user.update({
where: { id: inv.fund.userId },
data: { balance: { decrement: payout } },
}),
]
})
const tradeOps = keepHistory
? [
totalValue >= STARTING_BALANCE
? prisma.trade.create({
data: { userId: params.userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
})
: prisma.trade.create({
data: {
userId: params.userId,
type: 'BANKRUPTCY',
shares: 0,
price: 0,
total: STARTING_BALANCE - totalValue,
profit: STARTING_BALANCE - totalValue,
},
}),
prisma.trade.create({
data: { userId: params.userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE, createdAt: new Date(Date.now() + 1000) },
}),
]
: [
prisma.trade.deleteMany({ where: { userId: params.userId } }),
prisma.trade.create({
data: { userId: params.userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
}),
]
await prisma.$transaction([
...fundUpdates,
prisma.fundInvestment.deleteMany({ where: { userId: params.userId } }),
prisma.position.deleteMany({ where: { userId: params.userId } }),
prisma.userPortfolioHistory.deleteMany({ where: { userId: params.userId } }),
prisma.user.update({ where: { id: params.userId }, data: { balance: STARTING_BALANCE } }),
...tradeOps,
])
return NextResponse.json({ ok: true })
}
+70
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' import { z } from 'zod'
import { calcFundNav, round2 } from '@/lib/pricing'
const schema = z.object({ const schema = z.object({
balance: z.number().min(0).optional(), balance: z.number().min(0).optional(),
@@ -37,3 +38,72 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
return NextResponse.json(updated) return NextResponse.json(updated)
} }
/**
* DELETE /api/admin/users/[userId]
* Permanently deletes a user account.
* Fund investments are reconciled (sharesOutstanding decremented) before cascade deletion.
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: { userId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const user = await prisma.user.findUnique({
where: { id: params.userId },
select: {
id: true,
isFund: true,
fundInvestments: { select: { fundId: true, shares: true } },
},
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
if (user.isFund) {
return NextResponse.json(
{ error: 'Use the fund deletion endpoint to remove fund accounts.' },
{ status: 400 },
)
}
// Redeem each fund investment at current NAV — deduct payout from fund cash
for (const inv of user.fundInvestments) {
const fund = await prisma.hedgeFund.findUnique({
where: { id: inv.fundId },
select: {
id: true,
userId: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
},
},
},
},
})
if (!fund) continue
const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
const payout = round2(Math.max(0, inv.shares * nav))
await prisma.$transaction([
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: round2(Math.max(0, payout)) } } }),
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
])
}
await prisma.user.delete({ where: { id: params.userId } })
return NextResponse.json({ ok: true })
}
+12 -1
View File
@@ -36,9 +36,20 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12) const passwordHash = await bcrypt.hash(password, 12)
await prisma.user.create({ const user = await prisma.user.create({
data: { username, displayUsername, passwordHash }, data: { username, displayUsername, passwordHash },
}) })
await prisma.trade.create({
data: {
userId: user.id,
type: 'ACCOUNT_OPEN',
shares: 0,
price: 0,
total: 2000,
profit: 2000,
},
})
return NextResponse.json({ ok: true }, { status: 201 }) return NextResponse.json({ ok: true }, { status: 201 })
} }
+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 })
}
+10 -6
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { calcFundNav } from '@/lib/pricing' import { calcFundNav, round2 } from '@/lib/pricing'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -10,7 +10,7 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const slug = decodeURIComponent(params.slug).toLowerCase() const slug = decodeURIComponent(params.slug).toLowerCase()
const body = await req.json() const body = await req.json()
const amount = Number(body.amount) const amount = round2(Number(body.amount))
if (!amount || amount < 1) { if (!amount || amount < 1) {
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 }) return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
@@ -45,12 +45,12 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
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
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val return sum + val
}, 0) }, 0)
const totalValue = fund.user.balance + portfolioValue const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding) const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const sharesToMint = amount / nav const sharesToMint = Math.round((amount / nav) * 1e6) / 1e6
// Weighted average NAV at buy for display // Weighted average NAV at buy for display
const existingInvestment = await prisma.fundInvestment.findUnique({ const existingInvestment = await prisma.fundInvestment.findUnique({
@@ -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 } },
@@ -79,6 +79,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
}), }),
// Increment fund shares outstanding // Increment fund shares outstanding
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }), prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { increment: sharesToMint } } }),
// Log trade in activity history
prisma.trade.create({
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_INVEST', shares: sharesToMint, price: nav, total: amount, profit: 0 },
}),
]) ])
return NextResponse.json({ return NextResponse.json({
+15 -7
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { calcFundNav } from '@/lib/pricing' import { calcFundNav, round2 } from '@/lib/pricing'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) { export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@@ -35,34 +35,38 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
const investment = await prisma.fundInvestment.findUnique({ const investment = await prisma.fundInvestment.findUnique({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } }, where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
select: { shares: true }, select: { shares: true, avgNavAtBuy: true },
}) })
if (!investment || investment.shares < sharesToRedeem) { if (!investment || investment.shares < sharesToRedeem) {
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
: p.avgBuyPrice * p.shares - (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares : (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val return sum + val
}, 0) }, 0)
const totalValue = fund.user.balance + portfolioValue const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding) const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const payout = sharesToRedeem * nav const payout = round2(sharesToRedeem * nav)
if (fund.user.balance < payout) { if (fund.user.balance < payout) {
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 }) return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
} }
const remainingShares = investment.shares - sharesToRedeem const remainingShares = Math.round((investment.shares - sharesToRedeem) * 1e6) / 1e6
const profit = round2(payout - sharesToRedeem * investment.avgNavAtBuy)
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({
@@ -74,6 +78,10 @@ export async function POST(req: NextRequest, { params }: { params: { slug: strin
})]), })]),
// Decrement fund shares outstanding // Decrement fund shares outstanding
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }), prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: sharesToRedeem } } }),
// Log trade in activity history
prisma.trade.create({
data: { userId: session.user.id, fundId: fund.id, type: 'FUND_REDEEM', shares: sharesToRedeem, price: nav, total: payout, profit },
}),
]) ])
return NextResponse.json({ return NextResponse.json({
+6 -5
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { round2 } from '@/lib/pricing'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery' import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
function buildPrizes(): number[] { function buildPrizes(): number[] {
@@ -15,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()
) )
} }
@@ -25,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)
@@ -68,7 +69,7 @@ export async function POST(req: NextRequest) {
prisma.user.update({ prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
balance: { increment: winAmount }, balance: { increment: round2(winAmount) },
lastLotteryAt: now, lastLotteryAt: now,
}, },
}), }),
+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 },
)
}
+8 -4
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getPostsPerHour } from '@/lib/mastodon' import { getPostsData } from '@/lib/mastodon'
import { calcPrice } from '@/lib/pricing' import { calcPrice } from '@/lib/pricing'
import { normalizeTag } from '@/lib/utils' import { normalizeTag } from '@/lib/utils'
import { priceUpdateQueue } from '@/lib/queue' import { priceUpdateQueue } from '@/lib/queue'
@@ -49,8 +49,11 @@ export async function POST(req: NextRequest) {
// Query Mastodon // Query Mastodon
let postsPerHour = 0 let postsPerHour = 0
let hasAnyPosts = false
try { try {
postsPerHour = await getPostsPerHour(tag) const data = await getPostsData(tag)
postsPerHour = data.postsPerHour
hasAnyPosts = data.hasAnyPosts
} catch (err) { } catch (err) {
console.error('[research] Mastodon fetch failed:', err) console.error('[research] Mastodon fetch failed:', err)
return NextResponse.json( return NextResponse.json(
@@ -59,18 +62,19 @@ export async function POST(req: NextRequest) {
) )
} }
if (postsPerHour === 0) { if (!hasAnyPosts) {
// Deduct point for failed research // Deduct point for failed research
await prisma.user.update({ await prisma.user.update({
where: { id: session.user.id }, where: { id: session.user.id },
data: { researchPoints: { decrement: 1 } }, data: { researchPoints: { decrement: 1 } },
}) })
return NextResponse.json( return NextResponse.json(
{ error: 'No recent posts found for this hashtag. Research point spent.' }, { error: 'No posts found for this hashtag anywhere recently. Research point spent.' },
{ status: 404 }, { status: 404 },
) )
} }
// Use the last-hour price, or $0.25 minimum if active but currently quiet
const price = calcPrice(postsPerHour) const price = calcPrice(postsPerHour)
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000) const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
+27 -2
View File
@@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { calcTrade } from '@/lib/pricing' import { calcTrade, round2 } from '@/lib/pricing'
import { formatCurrency } from '@/lib/utils'
import { z } from 'zod' import { z } from 'zod'
const MAX_POSITION_SHARES = parseInt(process.env.MAX_POSITION_SHARES ?? '100', 10)
const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE ?? '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 tradeSchema = z.object({ const tradeSchema = z.object({
hashtagId: z.string().min(1), hashtagId: z.string().min(1),
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']), type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
@@ -63,6 +69,25 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 }) return NextResponse.json({ error: 'Insufficient balance.' }, { status: 400 })
} }
if (type === 'BUY_LONG' || type === 'BUY_SHORT') {
const maxShares = fundId ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES
const maxValue = fundId ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE
const newTotalShares = (existingPosition?.shares ?? 0) + shares
const newTotalValue = newTotalShares * hashtag.currentPrice
if (newTotalShares > maxShares) {
return NextResponse.json(
{ error: `Position limit: max ${maxShares.toLocaleString()} shares per hashtag.` },
{ status: 400 },
)
}
if (newTotalValue > maxValue) {
return NextResponse.json(
{ error: `Position limit: max ${formatCurrency(maxValue)} position value per hashtag.` },
{ status: 400 },
)
}
}
if (type === 'SELL_LONG') { if (type === 'SELL_LONG') {
if (!existingPosition || existingPosition.shares < shares) { if (!existingPosition || existingPosition.shares < shares) {
return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 }) return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 })
@@ -80,7 +105,7 @@ export async function POST(req: NextRequest) {
// Update user balance // Update user balance
await tx.user.update({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { balance: { increment: balanceDelta } }, data: { balance: { increment: round2(balanceDelta) } },
}) })
// Update / create position // Update / create position
+161
View File
@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { calcFundNav, round2 } from '@/lib/pricing'
const STARTING_BALANCE = 2000
/**
* POST /api/user/me/reset
* Body: { keepHistory?: boolean }
*
* Resets the current user's account. Deletes positions, fund investments, and
* portfolio history. When keepHistory is true the existing trade log is
* preserved and two bookmark entries are appended:
* • DONATION — user was in the green (totalValue ≥ $2k): records the value donated
* • BANKRUPTCY — user was in the red (totalValue < $2k): records the debt cleared
* • ACCOUNT_OPEN — always appended, records the fresh $2k grant
* When keepHistory is false all trades are also deleted.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const body = await req.json().catch(() => ({}))
const keepHistory = body.keepHistory === true
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
balance: true,
isFund: true,
fundInvestments: {
where: { shares: { gt: 0 } },
select: {
fundId: true,
shares: true,
fund: {
select: {
userId: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
avgBuyPrice: true,
positionType: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
},
},
},
},
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
avgBuyPrice: true,
positionType: true,
hashtag: { select: { currentPrice: true } },
},
},
},
})
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (user.isFund) {
return NextResponse.json({ error: 'Fund accounts cannot be reset this way.' }, { status: 400 })
}
const portfolioValue = user.positions.reduce((sum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const fundInvestmentValue = user.fundInvestments.reduce((sum, inv) => {
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return psum + val
}, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
return sum + Math.max(0, inv.shares * nav)
}, 0)
const totalValue = user.balance + portfolioValue + fundInvestmentValue
// Forfeit all fund investments — decrement sharesOutstanding and withdraw cash from fund
const fundUpdates = user.fundInvestments
.filter((inv) => inv.shares > 0)
.flatMap((inv) => {
const fundPortfolioValue = inv.fund.user.positions.reduce((psum, p) => {
const val =
p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return psum + val
}, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
const payout = Math.max(0, round2(inv.shares * nav))
return [
prisma.hedgeFund.update({
where: { id: inv.fundId },
data: { sharesOutstanding: { decrement: inv.shares } },
}),
prisma.user.update({
where: { id: inv.fund.userId },
data: { balance: { decrement: payout } },
}),
]
})
const tradeOps = keepHistory
? [
totalValue >= STARTING_BALANCE
? prisma.trade.create({
data: { userId, type: 'DONATION', shares: 0, price: 0, total: totalValue, profit: -totalValue },
})
: prisma.trade.create({
data: {
userId,
type: 'BANKRUPTCY',
shares: 0,
price: 0,
total: STARTING_BALANCE - totalValue,
profit: STARTING_BALANCE - totalValue,
},
}),
prisma.trade.create({
data: { userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE, createdAt: new Date(Date.now() + 1000) },
}),
]
: [
prisma.trade.deleteMany({ where: { userId } }),
prisma.trade.create({
data: { userId, type: 'ACCOUNT_OPEN', shares: 0, price: 0, total: STARTING_BALANCE, profit: STARTING_BALANCE },
}),
]
await prisma.$transaction([
...fundUpdates,
prisma.fundInvestment.deleteMany({ where: { userId } }),
prisma.position.deleteMany({ where: { userId } }),
prisma.userPortfolioHistory.deleteMany({ where: { userId } }),
prisma.user.update({ where: { id: userId }, data: { balance: STARTING_BALANCE } }),
...tradeOps,
])
return NextResponse.json({ ok: true })
}
+60
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { calcFundNav, round2 } from '@/lib/pricing'
const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase const USERNAME_RE = /^[a-z0-9_]{3,20}$/ // validated after toLowerCase
@@ -90,3 +91,62 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ ok: true, ...updated }) return NextResponse.json({ ok: true, ...updated })
} }
/**
* DELETE /api/user/me
* Permanently deletes the authenticated user's account.
* Fund investments are reconciled (sharesOutstanding decremented) before deletion.
*/
export async function DELETE() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
isFund: true,
fundInvestments: { select: { fundId: true, shares: true } },
},
})
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (user.isFund) return NextResponse.json({ error: 'Fund accounts cannot be self-deleted.' }, { status: 400 })
// Redeem each fund investment at current NAV — deduct payout from fund cash
for (const inv of user.fundInvestments) {
const fund = await prisma.hedgeFund.findUnique({
where: { id: inv.fundId },
select: {
id: true,
userId: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: { shares: true, avgBuyPrice: true, positionType: true, hashtag: { select: { currentPrice: true } } },
},
},
},
},
})
if (!fund) continue
const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const nav = calcFundNav(fund.user.balance + portfolioValue, fund.sharesOutstanding)
const payout = round2(Math.max(0, inv.shares * nav))
await prisma.$transaction([
prisma.user.update({ where: { id: fund.userId }, data: { balance: { decrement: payout } } }),
prisma.hedgeFund.update({ where: { id: fund.id }, data: { sharesOutstanding: { decrement: inv.shares } } }),
])
}
await prisma.user.delete({ where: { id: session.user.id } })
return NextResponse.json({ ok: true })
}
+3 -3
View File
@@ -136,7 +136,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
</div> </div>
{amountNum >= 1 && ( {amountNum >= 1 && (
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
You&apos;ll receive <span className="text-white font-medium">{previewShares.toFixed(4)} shares</span> You&apos;ll receive <span className="text-white font-medium">{previewShares.toFixed(6)} shares</span>
</p> </p>
)} )}
<button <button
@@ -154,7 +154,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
<input <input
type="number" type="number"
min="0" min="0"
step="0.0001" step="0.000001"
max={userShares} max={userShares}
value={shares} value={shares}
onChange={(e) => setShares(e.target.value)} onChange={(e) => setShares(e.target.value)}
@@ -167,7 +167,7 @@ export default function InvestPanel({ fundSlug, nav, userBalance, userShares, us
onClick={() => setShares(String(userShares))} onClick={() => setShares(String(userShares))}
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1" className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
> >
Max ({userShares.toFixed(4)}) Max ({userShares.toFixed(6)})
</button> </button>
)} )}
</div> </div>
+43 -1
View File
@@ -6,11 +6,33 @@ import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link' import Link from 'next/link'
import { Building2, TrendingUp, TrendingDown } from 'lucide-react' import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
import { calcFundNav } from '@/lib/pricing' import { calcFundNav } from '@/lib/pricing'
import InvestPanel from './InvestPanel' import InvestPanel from './InvestPanel'
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()
@@ -42,6 +64,16 @@ export default async function FundPage({ params }: { params: { slug: string } })
if (!fund) notFound() if (!fund) notFound()
// Fetch NAV history for the chart (last 7 days)
const navHistory = await prisma.fundNavHistory.findMany({
where: {
fundId: fund.id,
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
},
orderBy: { recordedAt: 'asc' },
select: { nav: true, recordedAt: true },
})
// Fetch current user's balance and investment in this fund // Fetch current user's balance and investment in this fund
const [currentUser, userInvestment] = session const [currentUser, userInvestment] = session
? await Promise.all([ ? await Promise.all([
@@ -77,6 +109,7 @@ export default async function FundPage({ params }: { params: { slug: string } })
return ( return (
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
<AutoRefresh intervalMs={30_000} />
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div> <div>
@@ -97,6 +130,15 @@ export default async function FundPage({ params }: { params: { slug: string } })
</div> </div>
</div> </div>
{/* NAV history chart */}
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-3">NAV / Share Last 7 Days</h2>
<PriceChart
data={navHistory.map((p) => ({ price: p.nav, recordedAt: p.recordedAt.toISOString() }))}
height={220}
/>
</div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{[ {[
@@ -125,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>
)
}
+97 -24
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,26 +12,37 @@ 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
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 }: 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
const maxBuyShares = hashtag.currentPrice > 0 ? Math.floor((balance / hashtag.currentPrice) * 100) / 100 : 0 // For buys: max is the lowest of (balance cap, shares cap, value cap) minus existing position
const existingBuyShares = tab === 'BUY_LONG' ? (longPosition?.shares ?? 0) : (shortPosition?.shares ?? 0)
const remainingShareCap = Math.max(0, maxPositionShares - existingBuyShares)
const remainingValueCap = Math.max(0, maxPositionValue - existingBuyShares * hashtag.currentPrice)
const sharesFromValueCap = hashtag.currentPrice > 0 ? remainingValueCap / hashtag.currentPrice : 0
const sharesFromBalance = hashtag.currentPrice > 0 ? Math.max(0, balance) / hashtag.currentPrice : 0
const maxBuyShares = Math.floor(Math.min(remainingShareCap, sharesFromValueCap, sharesFromBalance) * 100) / 100
const maxSellShares = const maxSellShares =
tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0 tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0
const canAfford = const canAfford =
tab === 'BUY_LONG' || tab === 'BUY_SHORT' ? cost <= balance : sharesNum <= (maxSellShares ?? 0) tab === 'BUY_LONG' || tab === 'BUY_SHORT' ? cost <= balance && sharesNum <= maxBuyShares : sharesNum <= (maxSellShares ?? 0)
async function handleTrade() { async function handleTrade() {
if (sharesNum <= 0) return if (sharesNum <= 0) return
@@ -56,17 +68,57 @@ 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> </div>
)} ) : managedFunds && managedFunds.length === 1 ? (
<Link
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(managedFunds[0].slug)}`}
className="flex items-center gap-2 text-xs border border-surface-border rounded-lg px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as <span className="font-medium text-slate-200">{managedFunds[0].name}</span></span>
<span className="ml-auto"></span>
</Link>
) : managedFunds && managedFunds.length > 1 ? (
<div className="text-xs border border-surface-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowFundMenu((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as a fund</span>
<span className="ml-auto text-slate-600">{showFundMenu ? '▲' : '▼'}</span>
</button>
{showFundMenu && (
<div className="border-t border-surface-border divide-y divide-surface-border">
{managedFunds.map((f) => (
<Link
key={f.slug}
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(f.slug)}`}
className="flex items-center justify-between px-3 py-2 font-medium text-indigo-300 hover:text-indigo-200 hover:bg-surface transition-colors"
>
<span>{f.name}</span>
<span className="text-slate-500"></span>
</Link>
))}
</div>
)}
</div>
) : null}
<div className="flex items-center justify-between"> <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">
Balance: <span className="text-white font-medium">{formatCurrency(balance)}</span> Balance: <span className={`font-medium ${balance < 0 ? 'text-red-400' : 'text-white'}`}>{formatCurrency(balance)}</span>
</span> </span>
</div> </div>
@@ -76,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'
@@ -84,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>
@@ -93,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
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p> return (
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</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-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
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p> return (
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</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-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>
@@ -128,7 +194,14 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition, fund
: String(maxSellShares) : String(maxSellShares)
)} )}
> >
Max {(tab === 'BUY_LONG' || tab === 'BUY_SHORT')
? balance <= 0
? <span className="text-red-400">Balance is negative</span>
: maxBuyShares === 0
? 'Max (limit reached)'
: 'Max'
: 'Max'
}
</button> </button>
</div> </div>
<input <input
+108 -12
View File
@@ -6,9 +6,19 @@ import { formatCurrency, formatNumber } from '@/lib/utils'
import { PriceChart } from '@/components/PriceChart' import { PriceChart } from '@/components/PriceChart'
import { TradePanel } from './TradePanel' import { TradePanel } from './TradePanel'
import { ResearchPanel } from './ResearchPanel' import { ResearchPanel } from './ResearchPanel'
import { Hash, Clock, Link as LinkIcon } from 'lucide-react' import { Hash, Clock, Link as LinkIcon, AlertTriangle } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link' import Link from 'next/link'
import { AutoRefresh } from '@/components/AutoRefresh'
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
const MAX_POSITION_SHARES = parseInt(process.env.MAX_POSITION_SHARES ?? '100', 10)
const MAX_POSITION_VALUE = parseInt(process.env.MAX_POSITION_VALUE ?? '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)
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -17,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(/^#+/, '')
@@ -27,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' },
@@ -76,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 (
@@ -123,6 +188,7 @@ export default async function HashtagPage({ params, searchParams }: Props) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<AutoRefresh intervalMs={30_000} />
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div> <div>
@@ -145,11 +211,35 @@ 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>
{/* Zombie warning — only shown when signed in, holding a position, and threshold reached */}
{session && (activeLong || activeShort) && hashtag.zeroCount >= ZOMBIE_ZERO_COUNT * 0.9 && (() => {
const zombieSince = formatDistanceToNow(
new Date(Date.now() - hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
)
const zombieEta = formatDistanceToNow(
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
{ addSuffix: true },
)
return (
<div className="bg-amber-500/5 border border-amber-500/25 rounded-xl p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">Auto-liquidation risk</p>
<p className="text-sm text-slate-400 mt-0.5">
No activity detected for <span className="text-slate-200">{zombieSince}</span>.
If this continues, your position will be force-closed{' '}
<span className="text-slate-200">{zombieEta}</span>.
</p>
</div>
</div>
)
})()}
{/* Trade panel or sign-in prompt */} {/* Trade panel or sign-in prompt */}
{session ? ( {session ? (
<TradePanel <TradePanel
@@ -164,6 +254,9 @@ export default async function HashtagPage({ params, searchParams }: Props) {
shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null} shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
fundId={fundContext?.id} fundId={fundContext?.id}
fundName={fundContext?.name} fundName={fundContext?.name}
maxPositionShares={fundContext ? FUND_MAX_POSITION_SHARES : MAX_POSITION_SHARES}
maxPositionValue={fundContext ? FUND_MAX_POSITION_VALUE : MAX_POSITION_VALUE}
managedFunds={managedFunds}
/> />
) : ( ) : (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center"> <div className="bg-surface-card border border-surface-border rounded-xl p-6 text-center">
@@ -209,7 +302,7 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
where: { hashtagId }, where: { hashtagId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 20, take: 20,
include: { user: { select: { username: true } } }, include: { user: { select: { username: true, displayUsername: true, isFund: true } } },
}) })
if (trades.length === 0) return null if (trades.length === 0) return null
@@ -230,12 +323,15 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
> >
{t.type.replace('_', ' ')} {t.type.replace('_', ' ')}
</span> </span>
<a <div className="flex items-center gap-1">
href={`/profile/${t.user.username}`} {t.user.isFund && <span className="text-indigo-400">🏦</span>}
className="text-slate-400 hover:text-slate-200" <Link
> href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
{t.user.username} className="text-slate-400 hover:text-slate-200"
</a> >
{t.user.displayUsername ?? t.user.username}
</Link>
</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<span className="text-slate-300">{formatNumber(t.shares)} sh</span> <span className="text-slate-300">{formatNumber(t.shares)} sh</span>
+49 -8
View File
@@ -30,6 +30,7 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
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 } },
}, },
}), }),
]) ])
@@ -59,23 +60,50 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
{trades.map((t) => { {trades.map((t) => {
const isLottery = t.type === 'LOTTERY_WIN' const isLottery = t.type === 'LOTTERY_WIN'
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
return ( return (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm"> <div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
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 ${
isLottery isLiquidation
? 'bg-amber-500/15 text-amber-400' ? 'bg-orange-500/15 text-orange-400'
: t.type.startsWith('BUY') : isLottery
? 'bg-emerald-500/15 text-emerald-400' ? 'bg-amber-500/15 text-amber-400'
: 'bg-red-500/15 text-red-400' : t.type === 'DONATION'
? 'bg-purple-500/15 text-purple-400'
: t.type === 'ACCOUNT_OPEN'
? 'bg-emerald-500/15 text-emerald-400'
: isFundTrade
? 'bg-indigo-500/15 text-indigo-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`} }`}
> >
{t.type.replace(/_/g, ' ')} {isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span> </span>
<div> <div>
{isLottery ? ( {isLottery ? (
<span className="text-amber-300">Lucky Dip</span> <span className="text-amber-300">Lucky Dip</span>
) : isSystemReset ? (
<span className="text-slate-300">
{t.type === 'DONATION'
? 'Account reset — donated'
: t.type === 'BANKRUPTCY'
? 'Bankruptcy declared'
: 'Account opened'}
</span>
) : isFundTrade ? (
t.fund ? (
<Link href={`/fund/${t.fund.slug}`} className="hover:text-indigo-300">
{t.fund.name}
</Link>
) : (
<span className="text-slate-500">Deleted Fund</span>
)
) : ( ) : (
<Link <Link
href={`/hashtag/${t.hashtag!.tag}`} href={`/hashtag/${t.hashtag!.tag}`}
@@ -90,13 +118,26 @@ export default async function TradeHistoryPage({ searchParams }: PageProps) {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
{isLottery ? ( {isLottery || t.type === 'ACCOUNT_OPEN' ? (
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p> <p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
) : isSystemReset ? (
<>
<p className="text-slate-500">{formatCurrency(t.total)}</p>
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
</>
) : isFundTrade ? (
<>
<p>{formatNumber(t.shares, 6)} sh @ {formatCurrency(t.price)}</p>
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
{t.type === 'FUND_REDEEM' && (
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
)}
</>
) : ( ) : (
<> <>
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p> <p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p> <p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && ( {(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
<p className={`text-xs ${pnlColor(t.profit)}`}> <p className={`text-xs ${pnlColor(t.profit)}`}>
{formatPnl(t.profit)} {formatPnl(t.profit)}
</p> </p>
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- dark indigo rounded background -->
<rect width="32" height="32" rx="7" fill="#1e1b4b"/>
<!-- # symbol — slightly angled vertical bars, two horizontal bars -->
<!-- left vertical bar (slants slightly: top-right to bottom-left) -->
<line x1="12" y1="6" x2="10" y2="26" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
<!-- right vertical bar -->
<line x1="20" y1="6" x2="18" y2="26" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
<!-- upper horizontal bar -->
<line x1="6" y1="13" x2="26" y2="13" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
<!-- lower horizontal bar -->
<line x1="5" y1="20" x2="25" y2="20" stroke="#a5b4fc" stroke-width="2.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

+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.',
} }
+20
View File
@@ -5,9 +5,28 @@ import { formatCurrency } from '@/lib/utils'
import { calcFundNav } from '@/lib/pricing' import { calcFundNav } from '@/lib/pricing'
import Link from 'next/link' 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 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 },
@@ -119,6 +138,7 @@ export default async function LeaderboardPage({
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Trophy className="h-7 w-7 text-amber-400" /> <Trophy className="h-7 w-7 text-amber-400" />
<div> <div>
+69 -4
View File
@@ -2,9 +2,14 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { HashtagCard } from '@/components/HashtagCard' import { HashtagCard } from '@/components/HashtagCard'
import { TrendingUp, Users, Hash } from 'lucide-react' import { TrendingUp, Users, Hash, AlertTriangle } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { formatPnl, pnlColor } from '@/lib/utils' import { formatPnl, pnlColor } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { AutoRefresh } from '@/components/AutoRefresh'
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
const PRICE_UPDATE_INTERVAL_MINUTES = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 export const revalidate = 0
@@ -59,14 +64,37 @@ async function getHoldings(userId: string) {
} }
} }
async function getZombieWarnings(userId: string) {
const threshold = Math.floor(ZOMBIE_ZERO_COUNT * 0.9)
const positions = await prisma.position.findMany({
where: { userId, shares: { gt: 0 }, hashtag: { zeroCount: { gte: threshold } } },
select: { hashtag: { select: { tag: true, displayTag: true, zeroCount: true } } },
})
return positions.map((p) => ({
tag: p.hashtag.tag,
displayTag: p.hashtag.displayTag,
zombieSince: formatDistanceToNow(
new Date(Date.now() - p.hashtag.zeroCount * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
),
zombieEta: formatDistanceToNow(
new Date(Date.now() + (ZOMBIE_ZERO_COUNT - p.hashtag.zeroCount) * PRICE_UPDATE_INTERVAL_MINUTES * 60 * 1000),
{ addSuffix: true },
),
}))
}
export default async function HomePage() { export default async function HomePage() {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings] = const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings, zombieWarnings] =
await Promise.all([getStats(), session ? getHoldings(session.user.id) : Promise.resolve(null)]) await Promise.all([
getStats(),
session ? getHoldings(session.user.id) : Promise.resolve(null),
session ? getZombieWarnings(session.user.id) : Promise.resolve([]),
])
return ( return (
<div className="space-y-10"> <div className="space-y-10">
{/* Hero */} <AutoRefresh intervalMs={30_000} />
<div className="text-center py-8"> <div className="text-center py-8">
<h1 className="text-4xl font-bold tracking-tight mb-3"> <h1 className="text-4xl font-bold tracking-tight mb-3">
The{' '} The{' '}
@@ -76,6 +104,9 @@ export default async function HomePage() {
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.
</p> </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
</Link>
<div className="flex justify-center gap-4 mt-6"> <div className="flex justify-center gap-4 mt-6">
{session ? ( {session ? (
<> <>
@@ -118,12 +149,46 @@ export default async function HomePage() {
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} /> <StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
</div> </div>
{/* Zombie / at-risk position warnings */}
{zombieWarnings.length > 0 && (
<section className="bg-amber-500/5 border border-amber-500/20 rounded-xl p-5">
<h2 className="font-semibold mb-1 flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-4 w-4" />
At-risk positions
</h2>
<p className="text-sm text-slate-400 mb-4">
The following hashtags have had no activity for an extended period and may be auto-liquidated soon.
Consider closing these positions manually.
</p>
<div className="flex flex-wrap gap-2">
{zombieWarnings.map((w) => (
<Link
key={w.tag}
href={`/hashtag/${w.tag}`}
title={`No activity for ${w.zombieSince}. Estimated liquidation ${w.zombieEta}.`}
className="inline-flex items-center gap-1.5 text-xs bg-amber-500/10 border border-amber-500/20 hover:border-amber-400/50 text-amber-300 hover:text-amber-200 px-3 py-1.5 rounded-full transition-colors"
>
<AlertTriangle className="h-3 w-3" />
#{w.displayTag}
<span className="text-amber-500/70">· liquidates {w.zombieEta}</span>
</Link>
))}
</div>
</section>
)}
{/* Holdings summary — biggest gain + biggest loss for signed-in users */} {/* Holdings summary — biggest gain + biggest loss for signed-in users */}
{holdings && (holdings.biggestGain ?? holdings.biggestLoss) && ( {holdings && (holdings.biggestGain ?? holdings.biggestLoss) && (
<section> <section>
<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 && (
+204 -64
View File
@@ -3,8 +3,10 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils' import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
import { calcFundNav } from '@/lib/pricing'
import Link from 'next/link' import Link from 'next/link'
import { Coins } from 'lucide-react' import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -37,13 +39,48 @@ function Sparkline({ prices }: { prices: number[] }) {
) )
} }
export default async function PositionsPage() { type SortKey = 'hashtag' | 'shares' | 'avgBuy' | 'current' | 'costBasis' | 'value' | 'pnl'
type SortDir = 'asc' | 'desc'
function SortHeader({
col,
label,
currentSort,
currentDir,
right = false,
className,
}: {
col: SortKey
label: string
currentSort: SortKey
currentDir: SortDir
right?: boolean
className?: string
}) {
const isActive = currentSort === col
const nextDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
const Icon = isActive ? (currentDir === 'desc' ? ChevronDown : ChevronUp) : ChevronsUpDown
return (
<Link
href={`?sort=${col}&dir=${nextDir}`}
className={`flex items-center gap-1 text-xs uppercase tracking-wider hover:text-slate-300 transition-colors ${isActive ? 'text-indigo-400' : 'text-slate-500'} ${right ? 'justify-end' : ''} ${className ?? ''}`}
>
<span>{label}</span>
<Icon className={`h-3 w-3 shrink-0${isActive ? '' : ' opacity-40'}`} />
</Link>
)
}
export default async function PositionsPage({
searchParams,
}: {
searchParams: { sort?: string; dir?: string }
}) {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session) redirect('/auth/signin') if (!session) redirect('/auth/signin')
const positions = await prisma.position.findMany({ const rawPositions = await prisma.position.findMany({
where: { userId: session.user.id, shares: { gt: 0 } }, where: { userId: session.user.id, shares: { gt: 0 } },
orderBy: { updatedAt: 'desc' },
include: { include: {
hashtag: { hashtag: {
select: { select: {
@@ -51,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 },
}, },
@@ -60,14 +97,91 @@ export default async function PositionsPage() {
}, },
}) })
const rawFundInvestments = await prisma.fundInvestment.findMany({
where: { userId: session.user.id, shares: { gt: 0 } },
include: {
fund: {
select: {
name: true,
slug: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
avgBuyPrice: true,
positionType: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
},
},
},
})
const fundHoldings = rawFundInvestments.map((inv) => {
const fundPortfolioValue = inv.fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const fundTotalValue = inv.fund.user.balance + fundPortfolioValue
const nav = calcFundNav(fundTotalValue, inv.fund.sharesOutstanding)
const currentValue = inv.shares * nav
const costBasis = inv.shares * inv.avgNavAtBuy
const pnl = currentValue - costBasis
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
return { ...inv, nav, currentValue, costBasis, pnl, pnlPct }
})
const positions = rawPositions.map((pos) => {
const pnl =
pos.positionType === 'LONG'
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares
const costBasis = pos.avgBuyPrice * pos.shares
const currentValue = pos.hashtag.currentPrice * pos.shares
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0
const sparkPrices = pos.hashtag.priceHistory.slice().reverse().map((h) => h.price)
return { ...pos, pnl, costBasis, currentValue, pnlPct, sparkPrices }
})
const validSorts = new Set<string>(['hashtag', 'shares', 'avgBuy', 'current', 'costBasis', 'value', 'pnl'])
const sortKey: SortKey = validSorts.has(searchParams.sort ?? '') ? (searchParams.sort as SortKey) : 'pnl'
const sortDir: SortDir = searchParams.dir === 'asc' ? 'asc' : 'desc'
positions.sort((a, b) => {
let av: number | string
let bv: number | string
switch (sortKey) {
case 'hashtag': av = a.hashtag.displayTag.toLowerCase(); bv = b.hashtag.displayTag.toLowerCase(); break
case 'shares': av = a.shares; bv = b.shares; break
case 'avgBuy': av = a.avgBuyPrice; bv = b.avgBuyPrice; break
case 'current': av = a.hashtag.currentPrice; bv = b.hashtag.currentPrice; break
case 'costBasis': av = a.costBasis; bv = b.costBasis; break
case 'value': av = a.currentValue; bv = b.currentValue; break
default: av = a.pnl; bv = b.pnl; break
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-indigo-400" /> <Coins className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Open Positions</h1> <h1 className="text-2xl font-bold">Open Positions</h1>
</div> </div>
{positions.length === 0 ? ( {positions.length === 0 && fundHoldings.length === 0 ? (
<div className="text-center py-16 text-slate-500"> <div className="text-center py-16 text-slate-500">
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" /> <Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>You have no open positions.</p> <p>You have no open positions.</p>
@@ -78,70 +192,96 @@ export default async function PositionsPage() {
) : ( ) : (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden"> <div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header row */} {/* Header row */}
<div className="grid grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 px-4 py-2 text-xs text-slate-500 uppercase tracking-wider border-b border-surface-border"> <div className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_4rem_5rem_5rem_6rem_5rem_6rem] gap-4 px-4 py-2 border-b border-surface-border">
<span>Hashtag</span> <SortHeader col="hashtag" label="Hashtag" currentSort={sortKey} currentDir={sortDir} />
<span className="text-right">Shares</span> <SortHeader col="shares" label="Shares" currentSort={sortKey} currentDir={sortDir} right />
<span className="text-right">Avg buy</span> <SortHeader col="avgBuy" label="Avg buy" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<span className="text-right">Current</span> <SortHeader col="current" label="Current" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<span className="text-right">Cost basis</span> <SortHeader col="costBasis" label="Cost basis" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<span className="text-right">Value</span> <SortHeader col="value" label="Value" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<span className="text-right">P&amp;L</span> <SortHeader col="pnl" label="P&L" currentSort={sortKey} currentDir={sortDir} right />
</div> </div>
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
{positions.map((pos) => { {positions.map((pos) => (
const pnl = <div
pos.positionType === 'LONG' key={pos.id}
? (pos.hashtag.currentPrice - pos.avgBuyPrice) * pos.shares className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_4rem_5rem_5rem_6rem_5rem_6rem] gap-4 items-center px-4 py-3"
: (pos.avgBuyPrice - pos.hashtag.currentPrice) * pos.shares >
const costBasis = pos.avgBuyPrice * pos.shares {/* Hashtag + type badge (+ sparkline on desktop) */}
const currentValue = pos.hashtag.currentPrice * pos.shares <div className="flex items-center gap-3 min-w-0">
const pnlPct = costBasis > 0 ? (pnl / costBasis) * 100 : 0 <div className="hidden sm:block shrink-0">
const sparkPrices = pos.hashtag.priceHistory.map((h) => h.price) <Sparkline prices={pos.sparkPrices} />
return (
<div
key={pos.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto_auto_auto] gap-4 items-center px-4 py-3"
>
{/* Hashtag + type + sparkline */}
<div className="flex items-center gap-3 min-w-0">
<Sparkline prices={sparkPrices} />
<div className="min-w-0">
<Link
href={`/hashtag/${pos.hashtag.tag}`}
className="font-medium hover:text-indigo-300 truncate block"
>
#{pos.hashtag.displayTag}
</Link>
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
pos.positionType === 'LONG'
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{pos.positionType}
</span>
</div>
</div> </div>
<div className="min-w-0">
<span className="text-right text-sm">{formatNumber(pos.shares)}</span> <Link
<span className="text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span> href={`/hashtag/${pos.hashtag.tag}`}
<span className="text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span> className="font-medium hover:text-indigo-300 truncate block"
<span className="text-right text-sm text-slate-400">{formatCurrency(costBasis)}</span> >
<span className="text-right text-sm">{formatCurrency(currentValue)}</span> #{pos.hashtag.displayTag}
</Link>
<div className="text-right"> <span
<p className={`text-sm font-medium ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p> className={`text-xs font-medium px-1.5 py-0.5 rounded ${
<p className={`text-xs ${pnlColor(pnlPct)}`}> pos.positionType === 'LONG'
{pnlPct >= 0 ? '+' : ''} ? 'bg-emerald-500/15 text-emerald-400'
{pnlPct.toFixed(1)}% : 'bg-red-500/15 text-red-400'
</p> }`}
>
{pos.positionType}
</span>
</div> </div>
</div> </div>
)
})} <span className="text-right text-sm">{formatNumber(pos.shares)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.avgBuyPrice)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.hashtag.currentPrice)}</span>
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(pos.costBasis)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(pos.currentValue)}</span>
<div className="text-right">
<p className={`text-sm font-medium ${pnlColor(pos.pnl)}`}>{formatPnl(pos.pnl)}</p>
<p className={`text-xs ${pnlColor(pos.pnlPct)}`}>
{pos.pnlPct >= 0 ? '+' : ''}
{pos.pnlPct.toFixed(1)}%
</p>
</div>
</div>
))}
</div>
</div>
)}
{fundHoldings.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 px-4 py-2 border-b border-surface-border text-xs uppercase tracking-wider text-slate-500">
<span>Fund</span>
<span className="text-right">Shares</span>
<span className="hidden sm:block text-right">Avg NAV</span>
<span className="hidden sm:block text-right">Cur NAV</span>
<span className="hidden sm:block text-right">Cost basis</span>
<span className="hidden sm:block text-right">Value</span>
<span className="text-right">P&amp;L</span>
</div>
<div className="divide-y divide-surface-border">
{fundHoldings.map((inv) => (
<div key={inv.id} className="grid grid-cols-[1fr_5rem_6rem] sm:grid-cols-[1fr_5rem_5rem_6rem_5rem_6rem] gap-4 items-center px-4 py-3">
<div className="min-w-0">
<Link href={`/fund/${inv.fund.slug}`} className="font-medium hover:text-indigo-300 truncate block">
{inv.fund.name}
</Link>
<span className="text-xs font-medium px-1.5 py-0.5 rounded bg-indigo-500/15 text-indigo-400">FUND</span>
</div>
<span className="text-right text-sm">{formatNumber(inv.shares, 6)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.avgNavAtBuy)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.nav)}</span>
<span className="hidden sm:block text-right text-sm text-slate-400">{formatCurrency(inv.costBasis)}</span>
<span className="hidden sm:block text-right text-sm">{formatCurrency(inv.currentValue)}</span>
<div className="text-right">
<p className={`text-sm font-medium ${pnlColor(inv.pnl)}`}>{formatPnl(inv.pnl)}</p>
<p className={`text-xs ${pnlColor(inv.pnlPct)}`}>{inv.pnlPct >= 0 ? '+' : ''}{inv.pnlPct.toFixed(1)}%</p>
</div>
</div>
))}
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,86 @@
'use client'
import { useState } from 'react'
import { signOut } from 'next-auth/react'
import { Trash2 } from 'lucide-react'
interface Props {
username: string
}
export default function CloseAccountForm({ username }: Props) {
const [open, setOpen] = useState(false)
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleDelete(e: React.FormEvent) {
e.preventDefault()
if (confirm !== username) {
setError('Username does not match.')
return
}
setLoading(true)
setError('')
try {
const res = await fetch('/api/user/me', { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Failed to delete account.')
return
}
await signOut({ callbackUrl: '/' })
} finally {
setLoading(false)
}
}
return (
<section className="bg-surface-card border border-red-500/20 rounded-xl p-6">
<button
onClick={() => { setOpen((v) => !v); setError(''); setConfirm('') }}
className="flex items-center gap-2 text-sm font-medium text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="h-4 w-4" />
Close account
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
</button>
{open && (
<form onSubmit={handleDelete} className="mt-4 space-y-4 max-w-sm">
<p className="text-sm text-slate-400">
This will <span className="text-red-400 font-medium">permanently delete</span> your account,
all trade history, positions, and portfolio data. This cannot be undone.
</p>
<p className="text-sm text-slate-400">
Any fund investments you hold will be forfeited to the fund.
</p>
<div>
<label className="block text-xs text-slate-400 mb-1">
Type <span className="text-white font-mono">{username}</span> to confirm
</label>
<input
type="text"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={username}
autoComplete="off"
className="w-full bg-surface border border-red-500/30 focus:border-red-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading || confirm !== username}
className="flex items-center gap-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
{loading ? 'Deleting…' : 'Permanently delete my account'}
</button>
</form>
)}
</section>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { RefreshCw } from 'lucide-react'
interface Props {
username: string
}
export default function ResetAccountForm({ username }: Props) {
const router = useRouter()
const [open, setOpen] = useState(false)
const [keepHistory, setKeepHistory] = useState(false)
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState(false)
async function handleReset(e: React.FormEvent) {
e.preventDefault()
if (confirm !== username) {
setError('Username does not match.')
return
}
setLoading(true)
setError('')
try {
const res = await fetch('/api/user/me/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepHistory }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error ?? 'Reset failed.')
return
}
setDone(true)
setOpen(false)
router.refresh()
} finally {
setLoading(false)
}
}
return (
<section id="reset" className="bg-surface-card border border-amber-500/20 rounded-xl p-6">
<button
onClick={() => { setOpen((v) => !v); setError(''); setConfirm(''); setDone(false) }}
className="flex items-center gap-2 text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors"
>
<RefreshCw className="h-4 w-4" />
Reset account
{done && <span className="text-emerald-400 text-xs ml-1"> Reset</span>}
<span className="ml-1 text-slate-500">{open ? '▲' : '▼'}</span>
</button>
{open && (
<form onSubmit={handleReset} className="mt-4 space-y-4 max-w-sm">
{keepHistory ? (
<p className="text-sm text-slate-400">
All positions and fund investments are closed. Your trade history is kept
and a <span className="text-purple-400 font-medium">DONATION</span> or{' '}
<span className="text-red-400 font-medium">BANKRUPTCY</span> entry marks the
reset, followed by an{' '}
<span className="text-emerald-400 font-medium">ACCOUNT OPEN</span>. Balance
resets to <span className="text-white font-medium">$2,000</span>.
</p>
) : (
<p className="text-sm text-slate-400">
This will <span className="text-amber-400 font-medium">permanently erase</span> your entire
trade history, all positions, and fund investments, then reset your cash balance back to{' '}
<span className="text-white font-medium">$2,000</span>. A true clean slate.
</p>
)}
{/* Keep history toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-300">Keep trade history</p>
<p className="text-xs text-slate-500">Add reset bookmarks instead of erasing</p>
</div>
<button
type="button"
onClick={() => setKeepHistory((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
keepHistory ? 'bg-amber-500' : 'bg-slate-600'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
keepHistory ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">
Type <span className="text-white font-mono">{username}</span> to confirm
</label>
<input
type="text"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={username}
autoComplete="off"
className="w-full bg-surface border border-amber-500/30 focus:border-amber-500 rounded-lg px-3 py-2 text-sm focus:outline-none"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading || confirm !== username}
className="flex items-center gap-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<RefreshCw className="h-4 w-4" />
{loading ? 'Resetting…' : 'Reset my account'}
</button>
</form>
)}
</section>
)
}
+176 -61
View File
@@ -3,11 +3,17 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils' import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { getBalanceTier } from '@/lib/pricing' import { getBalanceTier } from '@/lib/pricing'
import Link from 'next/link' import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins, Building2 } from 'lucide-react' import { TrendingUp, TrendingDown, Coins, Building2, AlertTriangle } from 'lucide-react'
import ChangePasswordForm from './ChangePasswordForm' import ChangePasswordForm from './ChangePasswordForm'
import AccountSettingsForm from './AccountSettingsForm' import AccountSettingsForm from './AccountSettingsForm'
import CloseAccountForm from './CloseAccountForm'
import ResetAccountForm from './ResetAccountForm'
import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -15,6 +21,26 @@ 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()
@@ -36,7 +62,10 @@ export default async function ProfilePage({ params }: Props) {
trades: { trades: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 30, take: 30,
include: { hashtag: { select: { tag: true, displayTag: true } } }, include: {
hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
},
}, },
managedFunds: { managedFunds: {
orderBy: { addedAt: 'asc' }, orderBy: { addedAt: 'asc' },
@@ -71,10 +100,26 @@ export default async function ProfilePage({ params }: Props) {
.filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') .filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT')
.reduce((sum, t) => sum + t.profit, 0) .reduce((sum, t) => sum + t.profit, 0)
const lotteryAggregate = await prisma.trade.aggregate({
where: { userId: user.id, type: 'LOTTERY_WIN' },
_sum: { profit: true },
_count: true,
})
const lotteryWinnings = lotteryAggregate._sum.profit ?? 0
const lotteryCount = lotteryAggregate._count
const portfolioHistory = await prisma.userPortfolioHistory.findMany({
where: {
userId: user.id,
recordedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
},
orderBy: { recordedAt: 'asc' },
select: { totalValue: true, recordedAt: true },
})
return ( return (
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1> <h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1>
{user.displayUsername && user.displayUsername.toLowerCase() !== user.username && ( {user.displayUsername && user.displayUsername.toLowerCase() !== user.username && (
@@ -91,9 +136,14 @@ export default async function ProfilePage({ params }: Props) {
<span className="text-slate-600 mx-1.5">&middot;</span> <span className="text-slate-600 mx-1.5">&middot;</span>
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day {tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
{tier.nextThreshold && ( {tier.nextThreshold && (
<span className="text-slate-600 text-xs ml-1.5"> <>
(next tier at {formatCurrency(tier.nextThreshold)}) <span className="hidden sm:inline text-slate-600 text-xs ml-1.5">
</span> (next tier at {formatCurrency(tier.nextThreshold)})
</span>
<span className="block sm:hidden text-slate-600 text-xs mt-0.5">
next tier at {formatCurrency(tier.nextThreshold)}
</span>
</>
)} )}
</p> </p>
<p> <p>
@@ -106,8 +156,13 @@ export default async function ProfilePage({ params }: Props) {
)} )}
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p> <p className={`text-3xl font-bold ${totalValue < 0 ? 'text-red-400' : ''}`}>{formatCurrency(totalValue)}</p>
<p className="text-sm text-slate-400">total portfolio value</p> <p className="text-sm text-slate-400">total portfolio value</p>
{lotteryCount > 0 && (
<p className="text-xs text-amber-400 mt-1">
🎰 {formatCurrency(lotteryWinnings)} from Lucky Dip ({lotteryCount} win{lotteryCount !== 1 ? 's' : ''})
</p>
)}
</div> </div>
</div> </div>
@@ -127,6 +182,64 @@ export default async function ProfilePage({ params }: Props) {
/> />
</div> </div>
{/* Bankruptcy warning — only shown to profile owner when cash balance is negative */}
{isOwn && user.balance < 0 && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 flex gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-400 mb-1">You&apos;re in the red</p>
<p className="text-sm text-slate-400">
Your cash balance is negative you won&apos;t be able to buy any positions until it recovers.
If you want to start fresh, you can{' '}
<a href="#reset" className="text-amber-400 hover:text-amber-300 underline underline-offset-2">reset your account</a>{' '}
back to $2,000.
</p>
</div>
</div>
)}
{/* Portfolio value chart */}
{portfolioHistory.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-3">Portfolio Value Last 7 Days</h2>
<PriceChart
data={portfolioHistory.map((p) => ({ price: p.totalValue, recordedAt: p.recordedAt.toISOString() }))}
height={200}
/>
</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>
@@ -180,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>
@@ -234,45 +316,76 @@ export default async function ProfilePage({ params }: Props) {
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
{user.trades.map((t) => { {user.trades.map((t) => {
const isLottery = t.type === 'LOTTERY_WIN' const isLottery = t.type === 'LOTTERY_WIN'
const isLiquidation = t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT'
const isSystemReset = t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'ACCOUNT_OPEN'
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
return ( return (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm"> <div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
<div className="flex items-center gap-3"> {/* Primary row: badge · hashtag/label · total */}
<div className="flex items-center gap-2">
<span <span
className={`text-xs font-medium px-2 py-0.5 rounded ${ className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
isLottery isLiquidation
? 'bg-amber-500/15 text-amber-400' ? 'bg-orange-500/15 text-orange-400'
: t.type.startsWith('BUY') : isLottery
? 'bg-emerald-500/15 text-emerald-400' ? 'bg-amber-500/15 text-amber-400'
: 'bg-red-500/15 text-red-400' : t.type === 'DONATION'
? 'bg-purple-500/15 text-purple-400'
: t.type === 'ACCOUNT_OPEN'
? 'bg-emerald-500/15 text-emerald-400'
: isFundTrade
? 'bg-indigo-500/15 text-indigo-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`} }`}
> >
{t.type.replace(/_/g, ' ')} {isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span> </span>
{isLottery ? ( {isLottery ? (
<span className="text-amber-300">Lucky Dip</span> <span className="text-amber-300 font-medium flex-1 min-w-0">Lucky Dip</span>
) : isSystemReset ? (
<span className="text-slate-300 font-medium flex-1 min-w-0">
{t.type === 'DONATION'
? 'Account reset — donated'
: t.type === 'BANKRUPTCY'
? 'Bankruptcy declared'
: 'Account opened'}
</span>
) : isFundTrade ? (
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="hover:text-indigo-300" 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(isLottery ? t.profit : t.total)}
</span>
</div> </div>
<div className="text-right"> {/* Secondary row: time (left) · shares @ price (right) */}
{isLottery ? ( <div className="flex items-center justify-between text-xs text-slate-500">
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p> <span>{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
) : ( {!isLottery && !isSystemReset && (
<> <span className="tabular-nums ml-3">{formatNumber(isFundTrade ? t.shares : t.shares)} sh @ {formatCurrency(t.price)}</span>
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
<p className={`text-xs ${pnlColor(t.profit)}`}>
{formatPnl(t.profit)}
</p>
)}
</>
)} )}
</div> </div>
{/* PnL: sell, liquidation, reset, and fund redeem trades */}
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation || t.type === 'DONATION' || t.type === 'BANKRUPTCY' || t.type === 'FUND_REDEEM') && (
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
)}
</div> </div>
) )
})} })}
@@ -289,6 +402,8 @@ export default async function ProfilePage({ params }: Props) {
currentDisplayUsername={user.displayUsername ?? null} currentDisplayUsername={user.displayUsername ?? null}
/> />
<ChangePasswordForm /> <ChangePasswordForm />
<ResetAccountForm username={user.username} />
<CloseAccountForm username={user.username} />
</> </>
)} )}
</div> </div>
+15 -15
View File
@@ -1,7 +1,8 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { formatCurrency } from '@/lib/utils' import { formatCurrency, pnlColor } from '@/lib/utils'
import Link from 'next/link' import Link from 'next/link'
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2, Building2 } from 'lucide-react' import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2, Building2 } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { calcFundNav } from '@/lib/pricing' import { calcFundNav } from '@/lib/pricing'
@@ -85,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 } } } } },
}, },
}) })
@@ -137,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) =>
@@ -197,6 +198,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
return ( return (
<div className="max-w-5xl mx-auto space-y-6"> <div className="max-w-5xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -243,16 +245,16 @@ export default async function StocksPage({ searchParams }: PageProps) {
{tab === 'stocks' && (<> {tab === 'stocks' && (<>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden"> <div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Column headers */} {/* Column headers */}
<div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium"> <div className="grid grid-cols-[2fr_1fr_1fr] sm:grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2.5 border-b border-surface-border text-xs font-medium">
<SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} fund={fund} /> <SortLink field="tag" label="Hashtag" currentSort={sort} currentDir={dir} page={page} fund={fund} />
<div className="text-right"> <div className="flex justify-end">
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} /> <SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div> </div>
<div className="text-right"> <div className="flex justify-end">
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} /> <SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div> </div>
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div> <div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
<div className="text-right"> <div className="hidden sm:flex justify-end">
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} /> <SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div> </div>
</div> </div>
@@ -265,12 +267,10 @@ export default async function StocksPage({ searchParams }: PageProps) {
const prev = stock.previousPrice const prev = stock.previousPrice
const change = prev != null ? stock.currentPrice - prev : null const change = prev != null ? stock.currentPrice - prev : null
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
const up = change == null ? null : change >= 0
return ( return (
<div <div
key={stock.id} key={stock.id}
className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors" className="grid grid-cols-[2fr_1fr_1fr] sm:grid-cols-[2fr_1fr_1fr_1fr_1fr] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
> >
{/* Rank + hashtag name */} {/* Rank + hashtag name */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
@@ -296,11 +296,11 @@ export default async function StocksPage({ searchParams }: PageProps) {
<span className="text-slate-600 text-xs"></span> <span className="text-slate-600 text-xs"></span>
) : ( ) : (
<div> <div>
<p className={`text-sm ${up ? 'text-emerald-400' : 'text-red-400'}`}> <p className={`text-sm ${pnlColor(change)}`}>
{up ? '+' : ''}{formatCurrency(change)} {change > 0 ? '+' : ''}{formatCurrency(change)}
</p> </p>
<p className={`text-xs ${up ? 'text-emerald-400' : 'text-red-400'}`}> <p className={`text-xs ${pnlColor(changePct ?? 0)}`}>
{up ? '+' : ''}{changePct!.toFixed(2)}% {changePct! > 0 ? '+' : ''}{changePct!.toFixed(2)}%
</p> </p>
</div> </div>
)} )}
@@ -312,7 +312,7 @@ export default async function StocksPage({ searchParams }: PageProps) {
</div> </div>
{/* Last updated */} {/* Last updated */}
<div className="text-right text-xs text-slate-500"> <div className="text-right text-xs text-slate-500 hidden sm:block">
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })} {formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
</div> </div>
</div> </div>
+61 -31
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 } },
}, },
}), }),
@@ -42,45 +55,62 @@ export default async function GlobalTradesPage({ searchParams }: PageProps) {
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden"> <div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
{trades.map((t) => ( {trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm"> <div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
<div className="flex items-center gap-3"> {/* Primary row: type badge · hashtag · total value */}
<div className="flex items-center gap-2">
<span <span
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.startsWith('BUY') (t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
? 'bg-emerald-500/15 text-emerald-400' ? 'bg-orange-500/15 text-orange-400'
: 'bg-red-500/15 text-red-400' : (t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM')
? 'bg-indigo-500/15 text-indigo-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`} }`}
> >
{t.type.replace('_', ' ')} {(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span> </span>
<div> {(t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM') ? (
<div className="flex items-center gap-1.5"> t.fund ? (
{t.user.isFund ? (
<span className="text-xs text-indigo-400">🏦</span>
) : null}
<Link <Link
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`} href={`/fund/${t.fund.slug}`}
className="text-slate-300 hover:text-white" className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
> >
{t.user.displayUsername ?? t.user.username} {t.fund.name}
</Link> </Link>
<span className="text-slate-600">·</span> ) : (
<Link href={`/hashtag/${t.hashtag!.tag}`} className="text-indigo-300 hover:text-indigo-200"> <span className="text-slate-500 font-medium flex-1 min-w-0">Deleted Fund</span>
#{t.hashtag!.displayTag} )
</Link> ) : (
</div> <Link
<p className="text-xs text-slate-500 mt-0.5"> href={`/hashtag/${t.hashtag!.tag}`}
{formatDistanceToNow(t.createdAt, { addSuffix: true })} className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
</p> >
</div> #{t.hashtag!.displayTag}
</div> </Link>
<div className="text-right shrink-0">
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
<p className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
<p className={`text-xs ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</p>
)} )}
<span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span>
</div> </div>
{/* Secondary row: user · time (left) shares @ price (right) */}
<div className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-1.5 min-w-0">
{t.user.isFund && <span className="text-indigo-400 shrink-0">🏦</span>}
<Link
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
className="text-slate-400 hover:text-slate-200 truncate"
>
{t.user.displayUsername ?? t.user.username}
</Link>
<span className="text-slate-700 shrink-0">·</span>
<span className="shrink-0">{formatDistanceToNow(t.createdAt, { addSuffix: true })}</span>
</div>
<span className="shrink-0 tabular-nums ml-3">{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</span>
</div>
{/* PnL: sell, liquidation, and fund redeem trades */}
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT' || t.type === 'FUND_REDEEM') && (
<div className={`text-xs text-right ${pnlColor(t.profit)}`}>{formatPnl(t.profit)}</div>
)}
</div> </div>
))} ))}
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
/**
* Silently refreshes all server-component data on the current page by calling
* router.refresh() on an interval and whenever the tab regains focus.
*
* Drop this anywhere inside a server-component page — it renders nothing.
*/
export function AutoRefresh({ intervalMs = 30_000 }: { intervalMs?: number }) {
const router = useRouter()
useEffect(() => {
const id = setInterval(() => router.refresh(), intervalMs)
const onFocus = () => router.refresh()
document.addEventListener('visibilitychange', onFocus)
return () => {
clearInterval(id)
document.removeEventListener('visibilitychange', onFocus)
}
}, [router, intervalMs])
return null
}
+11 -9
View File
@@ -1,5 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import { TrendingUp, TrendingDown } from 'lucide-react' import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
interface Props { interface Props {
@@ -16,7 +16,7 @@ export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, post
? ((currentPrice - previousPrice) / previousPrice) * 100 ? ((currentPrice - previousPrice) / previousPrice) * 100
: null : null
const up = pctChange === null ? null : pctChange >= 0 const up = pctChange === null ? null : pctChange > 0 ? 'up' : pctChange < 0 ? 'down' : 'flat'
return ( return (
<Link <Link
@@ -24,26 +24,28 @@ export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, post
className="block bg-surface-card border border-surface-border hover:border-indigo-500/50 rounded-xl p-4 transition-all hover:shadow-lg hover:shadow-indigo-500/5" className="block bg-surface-card border border-surface-border hover:border-indigo-500/50 rounded-xl p-4 transition-all hover:shadow-lg hover:shadow-indigo-500/5"
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div className="min-w-0">
<p className="font-semibold text-sm">#{displayTag}</p> <p className="font-semibold text-sm truncate">#{displayTag}</p>
{postsPerHour !== undefined && ( {postsPerHour !== undefined && (
<p className="text-xs text-slate-500 mt-0.5"> <p className="text-xs text-slate-500 mt-0.5">
{postsPerHour.toFixed(1)} posts/hr {postsPerHour.toFixed(1)} posts/hr
</p> </p>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right shrink-0">
<p className="font-bold text-sm">{formatCurrency(currentPrice)}</p> <p className="font-bold text-sm">{formatCurrency(currentPrice)}</p>
{pctChange !== null && ( {pctChange !== null && (
<div <div
className={`flex items-center justify-end gap-0.5 text-xs mt-0.5 ${up ? 'text-emerald-400' : 'text-red-400'}`} className={`flex items-center justify-end gap-0.5 text-xs mt-0.5 ${up === 'up' ? 'text-emerald-400' : up === 'down' ? 'text-red-400' : 'text-slate-400'}`}
> >
{up ? ( {up === 'up' ? (
<TrendingUp className="h-3 w-3" /> <TrendingUp className="h-3 w-3" />
) : ( ) : up === 'down' ? (
<TrendingDown className="h-3 w-3" /> <TrendingDown className="h-3 w-3" />
) : (
<Minus className="h-3 w-3" />
)} )}
{up ? '+' : ''} {up === 'up' ? '+' : ''}
{pctChange.toFixed(1)}% {pctChange.toFixed(1)}%
</div> </div>
)} )}
+88 -53
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 } 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 navigate(tag: string) {
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
router.push(url)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
function handleSearch(e: React.FormEvent) { function handleSearch(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const tag = normalizeTag(query) const tag = normalizeTag(query)
if (tag) { if (tag) navigate(tag)
router.push(`/hashtag/${tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
} }
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) { function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -51,6 +55,42 @@ export function Navbar() {
}, 300) }, 300)
} }
return (
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
value={query}
onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder={fundSlug ? `#hashtag (as ${fundSlug})` : '#hashtag'}
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface-card border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
{suggestions.map((s) => (
<button
key={s.tag}
type="button"
onMouseDown={() => navigate(s.tag)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
>
<span className="font-medium">#{s.displayTag}</span>
<span className="text-slate-400 text-xs">{formatCurrency(s.currentPrice)}</span>
</button>
))}
</div>
)}
</div>
</form>
)
}
export function Navbar() {
const { data: session } = useSession()
return ( return (
<nav className="border-b border-surface-border bg-surface-card"> <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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -61,41 +101,17 @@ export function Navbar() {
<span className="font-bold text-lg hidden sm:block">HashEx</span> <span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link> </Link>
{/* Search */} {/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
<form onSubmit={handleSearch} className="flex-1 max-w-md"> <Suspense fallback={
<div className="relative"> <div className="flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" /> <div className="relative">
<input <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
type="text" <input disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
value={query} </div>
onChange={handleQueryChange}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
placeholder="#hashtag"
className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface-card border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
{suggestions.map((s) => (
<button
key={s.tag}
type="button"
onMouseDown={() => {
router.push(`/hashtag/${s.tag}`)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-surface-border transition-colors"
>
<span className="font-medium">#{s.displayTag}</span>
<span className="text-slate-400 text-xs">{formatCurrency(s.currentPrice)}</span>
</button>
))}
</div>
)}
</div> </div>
</form> }>
<NavSearchInner />
</Suspense>
{/* Right section */} {/* Right section */}
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
@@ -155,21 +171,40 @@ export function Navbar() {
// Lazy balance fetcher so the navbar always shows current value // Lazy balance fetcher so the navbar always shows current value
function BalanceBadge({ userId }: { userId: string }) { function BalanceBadge({ userId }: { userId: string }) {
// We read balance from the API to stay fresh; use SWR-style approach
const [balance, setBalance] = useState<number | null>(null) const [balance, setBalance] = useState<number | null>(null)
// One-shot fetch on mount useEffect(() => {
if (typeof window !== 'undefined' && balance === null) { let cancelled = false
fetch('/api/user/me')
.then((r) => r.json()) function fetchBalance() {
.then((d) => setBalance(d.balance ?? null)) fetch('/api/user/me')
.catch(() => {}) .then((r) => r.json())
} .then((d) => { if (!cancelled) setBalance(d.balance ?? null) })
.catch(() => {})
}
fetchBalance()
// Re-fetch every 30 seconds
const interval = setInterval(fetchBalance, 30_000)
// Re-fetch when the tab regains focus
function onVisible() {
if (document.visibilityState === 'visible') fetchBalance()
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
clearInterval(interval)
document.removeEventListener('visibilitychange', onVisible)
}
}, [userId])
if (balance === null) return null if (balance === null) return null
return ( return (
<span className="text-emerald-400 text-sm font-medium hidden md:block"> <span className={`text-sm font-medium hidden md:block ${balance < 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{formatCurrency(balance)} {formatCurrency(balance)}
</span> </span>
) )
+37
View File
@@ -0,0 +1,37 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function TriggerJobButton({ queueName, label }: { queueName: string; label: string }) {
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const router = useRouter()
async function handleTrigger() {
setLoading(true)
setDone(false)
try {
await fetch(`/api/admin/queues/${encodeURIComponent(queueName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trigger' }),
})
setDone(true)
setTimeout(() => setDone(false), 3000)
} finally {
router.refresh()
setLoading(false)
}
}
return (
<button
onClick={handleTrigger}
disabled={loading}
className="text-xs px-2 py-1 rounded bg-indigo-500/20 text-indigo-400 hover:bg-indigo-500/30 disabled:opacity-50 transition-colors"
>
{loading ? 'Triggering…' : done ? '✓ Triggered' : label}
</button>
)
}
+75 -24
View File
@@ -31,12 +31,11 @@ function extractTagsFromHtml(html: string): string[] {
return results return results
} }
async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> { async function fetchPage(tag: string, maxId?: string, postLimit = 20): Promise<TimelineResult> {
const instance = process.env.MASTODON_INSTANCE const instance = process.env.MASTODON_INSTANCE
if (!instance) throw new Error('MASTODON_INSTANCE is not configured') if (!instance) throw new Error('MASTODON_INSTANCE is not configured')
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}` let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${postLimit}`
// ?limit=50 was here but it seemed too aggressive. Default is 20 which feels more balanced for pricing purposes and reduces risk of hitting rate limits on very active tags.
if (maxId) url += `&max_id=${maxId}` if (maxId) url += `&max_id=${maxId}`
const headers: HeadersInit = { Accept: 'application/json' } const headers: HeadersInit = { Accept: 'application/json' }
@@ -71,56 +70,86 @@ 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,
): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string }> { ): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string; hasAnyPosts: boolean }> {
const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10) const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10)
const postLimit = Math.min(parseInt(process.env.MASTODON_POST_LIMIT ?? '20', 10), 40)
const ONE_HOUR_MS = 60 * 60 * 1000 const ONE_HOUR_MS = 60 * 60 * 1000
const now = Date.now() const now = Date.now()
const cutoff = now - ONE_HOUR_MS const cutoff = now - ONE_HOUR_MS
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) const { posts, nextMaxId } = await fetchPage(tag, maxId, postLimit)
if (posts.length === 0) break if (posts.length === 0) break
allPosts = [...allPosts, ...posts] allPosts = [...allPosts, ...posts]
// End of timeline or no more pages // End of timeline or no more pages
if (posts.length < 40 || !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: [] } 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 } 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 }
} }
+29 -12
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)
} }
/** /**
@@ -39,6 +49,9 @@ export function getBalanceTier(balance: number): BalanceTier {
return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 } return { level: 1, pointsPerDay: 1, nextThreshold: 10_000 }
} }
/** Round a dollar amount to 2 decimal places for DB storage. */
export const round2 = (n: number) => Math.round(n * 100) / 100
/** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */ /** Calculate NAV (net asset value) per fund share. Returns 1.00 if no shares outstanding. */
export function calcFundNav(totalValue: number, sharesOutstanding: number): number { export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
if (sharesOutstanding <= 0) return 1.00 if (sharesOutstanding <= 0) return 1.00
@@ -78,8 +91,12 @@ export function calcTrade(
return { total, balanceDelta: -total, profit: 0 } return { total, balanceDelta: -total, profit: 0 }
} }
case 'SELL_SHORT': { case 'SELL_SHORT': {
const returned = Math.max(0, (2 * avgBuyPrice - price) * shares) // The collateral model: BUY_SHORT debited avgBuyPrice*shares. On close the
const profit = returned - avgBuyPrice * shares // formula (2*avgBuyPrice - price)*shares returns the net credit/debit so that
// the combined two-leg P&L equals (avgBuyPrice - price)*shares.
// No cap: when price > 2*avgBuyPrice the balance goes negative (realistic loss).
const returned = (2 * avgBuyPrice - price) * shares
const profit = returned - avgBuyPrice * shares // = (avgBuyPrice - price) * shares
return { total: returned, balanceDelta: returned, profit } return { total: returned, balanceDelta: returned, profit }
} }
} }
+8
View File
@@ -42,3 +42,11 @@ export const schedulerQueue = new Queue('hashex-scheduler', {
removeOnFail: { count: 5 }, removeOnFail: { count: 5 },
}, },
}) })
export const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', {
connection: redisOpts(),
defaultJobOptions: {
removeOnComplete: { count: 10 },
removeOnFail: { count: 10 },
},
})
+15 -2
View File
@@ -6,6 +6,15 @@ export function cn(...inputs: ClassValue[]) {
} }
export function formatCurrency(value: number): string { export function formatCurrency(value: number): string {
const abs = Math.abs(value)
if (abs >= 10_000) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
notation: 'compact',
maximumFractionDigits: 2,
}).format(value)
}
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: 'USD', currency: 'USD',
@@ -32,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*',
+218 -23
View File
@@ -13,7 +13,7 @@
import { Worker, Queue } from 'bullmq' import { Worker, Queue } from 'bullmq'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { getPostsData } from '../lib/mastodon' import { getPostsData } from '../lib/mastodon'
import { calcPrice, dailyResearchPoints } from '../lib/pricing' import { calcPrice, calcTrade, dailyResearchPoints, calcFundNav, round2 } from '../lib/pricing'
// ── Connection options ──────────────────────────────────────────────────────── // ── Connection options ────────────────────────────────────────────────────────
// Use plain connection options so BullMQ uses its own bundled ioredis, // Use plain connection options so BullMQ uses its own bundled ioredis,
@@ -43,6 +43,7 @@ const prisma = new PrismaClient({
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10) const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10) const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
function activeUntilFromNow(): Date { function activeUntilFromNow(): Date {
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000) return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
@@ -53,6 +54,48 @@ function activeUntilFromNow(): Date {
const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() }) const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() })
const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() }) const maintenanceQueue = new Queue('hashex-maintenance', { connection: redisOpts() })
const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() }) const schedulerQueue = new Queue('hashex-scheduler', { connection: redisOpts() })
const fundNavSnapshotQueue = new Queue('hashex-fund-nav-snapshot', { connection: redisOpts() })
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Force-close every open position on a hashtag at the given price.
* Creates SELL_LONG / SELL_SHORT trade records and credits users' balances.
* Returns the number of positions closed.
*/
async function forceClosePositions(hashtagId: string, price: number, tag: string): Promise<number> {
const positions = await prisma.position.findMany({
where: { hashtagId, shares: { gt: 0 } },
})
for (const pos of positions) {
const type = pos.positionType === 'LONG' ? 'LIQUIDATE_LONG' as const : 'LIQUIDATE_SHORT' as const
const calcType = pos.positionType === 'LONG' ? 'SELL_LONG' as const : 'SELL_SHORT' as const
const { total, balanceDelta, profit } = calcTrade(calcType, pos.shares, price, pos.avgBuyPrice)
await prisma.$transaction([
prisma.user.update({
where: { id: pos.userId },
data: { balance: { increment: round2(balanceDelta) } },
}),
prisma.position.update({
where: { id: pos.id },
data: { shares: 0 },
}),
prisma.trade.create({
data: {
userId: pos.userId,
hashtagId,
type,
shares: pos.shares,
price,
total,
profit,
},
}),
])
console.log(`[price] #${tag} force-closed ${type} for user ${pos.userId}${pos.shares} sh @ $${price.toFixed(2)}, P&L $${profit.toFixed(2)}`)
}
return positions.length
}
// ── Workers ─────────────────────────────────────────────────────────────────── // ── Workers ───────────────────────────────────────────────────────────────────
@@ -85,27 +128,54 @@ const priceWorker = new Worker(
if (postsPerHour === 0) { if (postsPerHour === 0) {
const newZeroCount = hashtag.zeroCount + 1 const newZeroCount = hashtag.zeroCount + 1
const shouldDeactivate = ttlExpired && ownerCount === 0
await prisma.hashtag.update({ // Zombie threshold: retire the hashtag and force-close any remaining positions
where: { id: hashtagId }, if (newZeroCount >= ZOMBIE_ZERO_COUNT) {
data: { if (ownerCount > 0) {
zeroCount: newZeroCount, const closed = await forceClosePositions(hashtagId, hashtag.currentPrice, tag)
isActive: shouldDeactivate ? false : hashtag.isActive, console.log(`[price] #${tag} zombie threshold (zeroCount=${newZeroCount}) — force-closed ${closed} position(s)`)
lastUpdated: new Date(), }
}, await prisma.hashtag.update({
}) where: { id: hashtagId },
data: { zeroCount: newZeroCount, isActive: false, lastUpdated: new Date() },
})
console.log(`[price] #${tag} retired (zeroCount=${newZeroCount})`)
return
}
const shouldDeactivate = ttlExpired && ownerCount === 0
const floorPrice = calcPrice(0)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId },
data: {
zeroCount: newZeroCount,
isActive: shouldDeactivate ? false : hashtag.isActive,
lastUpdated: new Date(),
},
}),
// Record floor price in history while the stock is still active so the chart has no gaps
...(!shouldDeactivate ? [prisma.priceHistory.create({
data: { hashtagId, price: floorPrice, postsPerHour: 0 },
})] : []),
])
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated (TTL expired, no holders)' : ''}`) 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)
where: { id: hashtagId }, await prisma.$transaction([
data: { isActive: false, lastUpdated: new Date() }, prisma.hashtag.update({
}) where: { id: hashtagId },
console.log(`[price] #${tag} deactivated — TTL expired, no holders`) data: { currentPrice: finalPrice, isActive: false, lastUpdated: new Date() },
}),
prisma.priceHistory.create({
data: { hashtagId, price: finalPrice, postsPerHour },
}),
])
console.log(`[price] #${tag} deactivated — TTL expired, no holders (final price $${finalPrice.toFixed(2)})`)
return return
} }
@@ -177,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)
@@ -224,6 +294,114 @@ const maintenanceWorker = new Worker(
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` + `[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
`inactive: ${deletedInactive.count} rows removed (>${inactiveHours}h)`, `inactive: ${deletedInactive.count} rows removed (>${inactiveHours}h)`,
) )
// ── Snapshot history pruning (7 days for both) ─────────────────────────
const snapshotCutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const [deletedFundNav, deletedUserPortfolio] = await Promise.all([
prisma.fundNavHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
prisma.userPortfolioHistory.deleteMany({ where: { recordedAt: { lt: snapshotCutoff } } }),
])
console.log(
`[maintenance] pruned snapshots — fund NAV: ${deletedFundNav.count} rows, user portfolio: ${deletedUserPortfolio.count} rows (>7d)`,
)
// ── Related hashtag pruning ────────────────────────────────────────────
// 1. Null-target records: the related hashtag was deleted (onDelete: SetNull)
// or was never tracked. Delete unconditionally — the upsert in the price
// worker will recreate them if the co-occurrence is seen again.
// 2. Weak associations: low co-occurrence records that were never reinforced,
// pruned after 30 days of inactivity.
const relatedCutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const [deletedNullTarget, deletedWeak] = await Promise.all([
prisma.relatedHashtag.deleteMany({ where: { relatedId: null } }),
prisma.relatedHashtag.deleteMany({
where: { relatedId: { not: null }, coOccurrences: { lt: 5 }, updatedAt: { lt: relatedCutoff } },
}),
])
console.log(
`[maintenance] pruned relatedHashtag — ${deletedNullTarget.count} null-target, ${deletedWeak.count} weak (>30d, <5 co-occurrences)`,
)
},
{ connection: redisOpts() },
)
/**
* Fund NAV snapshot worker — records the current NAV of every fund once per hour.
*/
const fundNavSnapshotWorker = new Worker(
'hashex-fund-nav-snapshot',
async (job) => {
console.log(`[fund-nav] snapshotting all fund NAVs (job ${job.id})`)
const funds = await prisma.hedgeFund.findMany({
select: {
id: true,
sharesOutstanding: true,
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
positionType: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
},
},
},
})
for (const fund of funds) {
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)
const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
await prisma.fundNavHistory.create({
data: { fundId: fund.id, nav, totalValue },
})
}
console.log(`[fund-nav] snapshotted ${funds.length} fund(s)`)
// ── User portfolio snapshots ──────────────────────────────────────────
const regularUsers = await prisma.user.findMany({
where: { isFund: false },
select: {
id: true,
balance: true,
positions: {
where: { shares: { gt: 0 } },
select: {
shares: true,
positionType: true,
avgBuyPrice: true,
hashtag: { select: { currentPrice: true } },
},
},
},
})
for (const user of regularUsers) {
const portfolioValue = 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)
const totalValue = user.balance + portfolioValue
await prisma.userPortfolioHistory.create({
data: { userId: user.id, totalValue, portfolioValue },
})
}
console.log(`[fund-nav] snapshotted ${regularUsers.length} user portfolio(s)`)
}, },
{ connection: redisOpts() }, { connection: redisOpts() },
) )
@@ -277,7 +455,12 @@ const schedulerWorker = new Worker(
// ── Error handlers ──────────────────────────────────────────────────────────── // ── Error handlers ────────────────────────────────────────────────────────────
// Worker-level connection errors (separate from per-job failures) // Worker-level connection errors (separate from per-job failures)
for (const [name, worker] of [['price', priceWorker], ['maintenance', maintenanceWorker], ['scheduler', schedulerWorker]] as const) { for (const [name, worker] of [
['price', priceWorker],
['maintenance', maintenanceWorker],
['fund-nav', fundNavSnapshotWorker],
['scheduler', schedulerWorker],
] as const) {
worker.on('error', (err) => { worker.on('error', (err) => {
console.error(`[${name}-worker] connection error:`, err.message) console.error(`[${name}-worker] connection error:`, err.message)
}) })
@@ -296,16 +479,18 @@ async function setupRepeatableJobs() {
// Always wipe existing repeatable registrations first so that: // Always wipe existing repeatable registrations first so that:
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist // - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly // - jobs exhausted by BullMQ retry limits get rescheduled cleanly
const [existingScheduler, existingMaintenance] = await Promise.all([ const [existingScheduler, existingMaintenance, existingFundNav] = await Promise.all([
schedulerQueue.getRepeatableJobs(), schedulerQueue.getRepeatableJobs(),
maintenanceQueue.getRepeatableJobs(), maintenanceQueue.getRepeatableJobs(),
fundNavSnapshotQueue.getRepeatableJobs(),
]) ])
await Promise.all([ await Promise.all([
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)), ...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)), ...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
...existingFundNav.map((j) => fundNavSnapshotQueue.removeRepeatableByKey(j.key)),
]) ])
if (existingScheduler.length || existingMaintenance.length) { if (existingScheduler.length || existingMaintenance.length || existingFundNav.length) {
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance repeatable(s)`) console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance + ${existingFundNav.length} fund-nav repeatable(s)`)
} }
// Price update sweep — every N minutes // Price update sweep — every N minutes
@@ -317,12 +502,21 @@ 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 15 minutes
await fundNavSnapshotQueue.add(
'fund-nav-snapshot',
{},
{
repeat: { pattern: '*/15 * * * *' },
}, },
) )
@@ -345,6 +539,7 @@ async function shutdown() {
console.log('[worker] shutting down…') console.log('[worker] shutting down…')
await priceWorker.close() await priceWorker.close()
await maintenanceWorker.close() await maintenanceWorker.close()
await fundNavSnapshotWorker.close()
await schedulerWorker.close() await schedulerWorker.close()
await prisma.$disconnect() await prisma.$disconnect()
process.exit(0) process.exit(0)
+1 -1
View File
File diff suppressed because one or more lines are too long