Compare commits

..

112 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
ThaMunsta 589763fa44 data growth features
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-19 01:25:22 -04:00
ThaMunsta c78045e2b9 add admin toggle to user actions; update state management for admin status
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-19 01:16:33 -04:00
ThaMunsta e06caa6d0c update user count query to exclude hidden users; enhance modal backdrop styling
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-19 01:13:43 -04:00
ThaMunsta eb9e8eeecb fully ignore tags object for caps
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-19 01:07:12 -04:00
ThaMunsta 64ae9c1082 correction to hashtag handling
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-19 01:04:17 -04:00
ThaMunsta ec275dd858 better hashtag handling
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-19 00:52:17 -04:00
ThaMunsta 20f939799d fix fund fk on deletes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-18 23:14:33 -04:00
ThaMunsta f54a332345 fix autocomplete on /admin/funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-18 22:37:23 -04:00
ThaMunsta 039e2662f7 Funds you manage page and fix Trade on behalf links 2026-03-18 22:33:01 -04:00
ThaMunsta bfd891e977 trade history for NAV fund investments
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-18 22:29:09 -04:00
ThaMunsta 63e1821e98 improve stocks page for funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m22s
2026-03-18 22:16:25 -04:00
ThaMunsta 39dd864245 making funds work better and some refactor
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-18 22:07:21 -04:00
ThaMunsta 1ab8763089 Refactor code structure for improved readability and maintainability
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-18 21:42:07 -04:00
ThaMunsta 3b40f0f869 Refactor code structure for improved readability and maintainability
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-18 21:23:01 -04:00
ThaMunsta 8aca2ae14e Refactor code structure for improved readability and maintainability
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-18 21:13:28 -04:00
ThaMunsta 7bd99e8fcb feat: enhance price update job handling with deduplication and retry logic
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-18 20:59:34 -04:00
ThaMunsta 8e808d5e7c feat: add investment and redemption functionality for hedge funds
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
- Implemented POST endpoint for investing in hedge funds, allowing users to invest a specified amount and receive shares based on the fund's NAV.
- Implemented POST endpoint for redeeming shares in hedge funds, enabling users to redeem shares for cash based on the current NAV.
- Created InvestPanel component for user interactions, including investing and redeeming shares, displaying current NAV, user balance, and holdings summary.
2026-03-18 20:50:51 -04:00
ThaMunsta 50b1b3472f feat: enhance trade feed with pagination and detailed trade information
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m29s
2026-03-18 20:07:30 -04:00
ThaMunsta 03ee361f29 feat: add hedge fund management features including creation, deletion, and manager assignments
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m23s
2026-03-18 19:59:15 -04:00
ThaMunsta 1ce1511954 feat: implement balance tier system and update research points allocation in maintenance worker 2026-03-18 19:28:29 -04:00
ThaMunsta 45d3c62cae feat: add RetryFailedButton component and API endpoint for retrying failed queue jobs
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m19s
2026-03-18 19:17:06 -04:00
ThaMunsta 98f08ece11 feat: add lottery reset functionality in admin user actions and API
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m20s
2026-03-18 19:13:16 -04:00
ThaMunsta d11e2e7b9a feat: add trade history page and update profile links
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
2026-03-18 19:05:09 -04:00
ThaMunsta 6b32b28af1 feat: implement lottery win feature and update related components
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
2026-03-18 18:56:11 -04:00
ThaMunsta af5484f0cd feat: add hashtag active duration and extend active window on sell
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
2026-03-18 18:51:42 -04:00
ThaMunsta 561b4d2faf fix tag case, holdings summary, autocomplete, dedicated page for positions
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
2026-03-18 18:40:14 -04:00
ThaMunsta b763e011e9 roadmap changes
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m21s
2026-03-18 18:28:50 -04:00
ThaMunsta e8a5165047 roadmap changes 2026-03-18 18:22:05 -04:00
66 changed files with 6446 additions and 495 deletions
+7
View File
@@ -17,8 +17,15 @@ MASTODON_ACCESS_TOKEN="your-mastodon-access-token"
WORKER_RATE_LIMIT_MS=2000
# How often (minutes) to queue a full price-update sweep (default: 60)
PRICE_UPDATE_INTERVAL_MINUTES=60
# How long (hours) a hashtag stays active after being researched or after its last position closes (default: 24)
HASHTAG_ACTIVE_HOURS=24
# Max pagination pages to fetch when counting posts (default: 5 = up to 200 posts)
MAX_PAGES_PER_HASHTAG=5
# Price history retention: days to keep for active hashtags, hours for inactive ones
PRICE_HISTORY_ACTIVE_DAYS=7
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.
# Pass these at seed time: docker exec -e ADMIN_USERNAME=x -e ADMIN_PASSWORD=y <container> npm run db:seed
+45 -115
View File
@@ -152,27 +152,32 @@ All variables are documented in `.env.example`. Key ones:
## Pricing Formula
Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices:
```
price = max($0.25, round(postsPerHour × $0.25, 2))
price = max($0.25, round((base × pph) / (1 + k × pph), 2))
```
Examples:
`k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second).
| Posts/hr | Price |
|---|---|
| 1 | $0.25 |
| 10 | $2.50 |
| 100 | $25.00 |
| 1,000 | $250.00 |
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 |
| 1 | ~$0.25 |
| 10 | ~$2.48 |
| 100 | ~$23.32 |
| 1,000 | ~$145 |
| 3,600 (one post/sec) | ~$250 |
| ∞ (theoretical) | ~$346 (asymptote) |
**Burst handling:** when all fetched posts share a very tight timestamp window the worker paginates up to `MAX_PAGES_PER_HASHTAG` pages to get a realistic count before the span grows to > 5 minutes.
At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market.
**Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window.
---
## Research System
- Every player earns **1 research point per day** (awarded at 00:05 UTC by the maintenance worker).
- Every player earns **1 research point per day** (awarded at midnight EST by the maintenance worker).
- Balance milestones unlock extra daily points:
| Balance | Daily points |
@@ -217,7 +222,7 @@ Three BullMQ queues:
|---|---|
| `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
| `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. |
| `hashex-maintenance` | Runs daily at 00:05 UTC. Awards research points based on each player's balance. |
| `hashex-maintenance` | Runs daily at midnight EST. Awards research points based on each player's balance. |
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
@@ -246,6 +251,35 @@ Accessible only to users with `isAdmin = true`.
- Live view of all three BullMQ queues
- Shows waiting, active, delayed, completed, and failed counts
- Lists active, waiting, and recent failed jobs with payload and failure reason
- **Retry failed** button per queue to requeue all failed jobs
### Hedge Funds (`/admin/funds`)
- Create a named hedge fund with an initial starting balance
- Add or remove managers (by username) per fund
- Delete a fund (removes all its positions and trade history)
---
## Hedge Funds
Multiple players can collaborate via a **Hedge Fund** — a shared pool of capital with its own balance, positions, and trade history.
### How it works
1. An admin creates a fund at `/admin/funds` (providing a name and initial balance).
2. The admin adds one or more players as **managers** of the fund.
3. A manager visits `/fund/[slug]` to see the fund's portfolio.
4. From there they can click any held position (or browse all stocks) — the link includes `?fund=[slug]`, putting the hashtag trade page in **Fund Mode**.
5. In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's.
6. The manager can return to their `/profile` at any time to trade under their own account.
### Fund page (`/fund/[slug]`)
- Public — anyone can view the fund name, balance, positions, managers, and trade history.
- Managers see a management panel with quick links to trade each held position in Fund Mode.
### Key rules
- A fund account cannot sign in directly — it is a shadow account controlled via the manager interface.
- A user can manage multiple funds simultaneously.
- Funds do not earn research points or play the daily lottery.
---
@@ -316,7 +350,6 @@ npm run start # Start production web server
npm run worker:dev # Worker with hot reload (tsx watch)
npm run worker:prod # Worker for production
npm run db:push # Apply schema changes without migrations
npm run db:migrate # Create and apply a migration
npm run db:seed # Seed the admin user
npm run db:studio # Open Prisma Studio (GUI)
```
@@ -329,113 +362,10 @@ The items below are planned improvements roughly ordered by user value. They are
---
### 1. Username Display Names & Password Changes on Profile
**Problem:** Usernames are forced lowercase on registration (some users want "JohnDoe" not "johndoe"). Users also have no self-service way to change their password.
**Plan:**
- Add a `displayUsername` field to `User` — stores the original mixed-case version typed at signup. Lookups/URLs continue using the lowercase `username` for uniqueness and consistency.
- Profile settings page (`/profile/[username]/settings` or a tab on the existing profile) with:
- **Change display name** — updates `displayUsername` only; the canonical `username` remains lowercase and immutable (avoids breaking existing links/positions).
- **Change username** — updates both `username` and `displayUsername`; requires confirming no conflicts; all foreign key relations in Prisma use the DB `id` so no cascade issues.
- **Change password** — classic current-password + new-password + confirm form; server endpoint `PATCH /api/user/me` validating current hash before updating.
- All places that show a username (Navbar, profile page, leaderboard, trade history) should render `displayUsername` if set, falling back to `username`.
---
### 2. Leaderboard
**Problem:** No public way to see who the top players are or browse other users' portfolios.
**Plan:**
- New page `/leaderboard` showing top N players sorted by **net worth** (balance + sum of position value at current prices).
- Table columns: rank, avatar/username link → their public profile, net worth, total trades, biggest single position.
- Public profiles — any user can view `/profile/[username]`; sensitive data (exact balance) optionally hidden from non-owners/non-admins.
- Navbar: show **Leaderboard** link when signed in (replacing or supplementing the current unauthenticated-only links).
- Consider a separate "all-time best trade" leaderboard tab for engagement.
---
### 3. Lucky Dip Lottery
**Problem:** Players who go broke have no recovery mechanic; there is no daily engagement hook beyond trading.
**Plan:**
- New page `/lottery`.
- Once per day (tracked by a `lastLotteryAt` timestamp on `User`), a player who has **$0 or less** can reveal one hidden box from a grid (e.g. 5 × 5 = 25 boxes).
- One box is the winner (selected server-side at draw time, stored encrypted or revealed only on pick to prevent client-side cheating).
- **Prizes** (configurable): e.g. one box pays $100, a few pay $10, the rest pay $0.
- API: `POST /api/lottery/pick` — validates cooldown, selects winner server-side, awards balance, returns outcome.
- Consider allowing players with any balance to play for a small stake (e.g. pay $5 to play, win up to $200) to make it useful beyond the broke scenario.
- Future: admin-configurable prize pool and grid size.
---
### 4. Home Page Nav Changes (Signed-In vs Signed-Out)
**Problem:** Home page shows the same call-to-action links regardless of auth state.
**Plan:**
- When **signed out**: show Sign Up / Sign In CTAs as currently.
- When **signed in**: replace or supplement with quick links to **Leaderboard**, **Lottery**, and the user's own profile.
- Navbar already hides/shows some items; extend that logic to the hero section of the home page.
---
### 5. Related Hashtags
**Problem:** No discovery path from one hashtag to related ones.
**Plan:**
- During price update jobs the worker already fetches up to 200 posts. Parse each post's `tags` array (present in the Mastodon API response) to collect co-occurring hashtags.
- Store co-occurrence counts in a new `RelatedHashtag` table: `(hashtagId, relatedTag, coOccurrences)` — upsert on each price update, incrementing counts.
- On the hashtag detail page, show the top 5 most co-occurring tags as "Related" chips that link to their own pages.
- Only surface related tags that are themselves active (or researchable) — avoid surfacing banned/inactive noise.
- Worker change: `MastodonPost` type already has `content`; add `tags: { name: string }[]` to the interface and parse it in `mastodon.ts`.
---
### 6. Admin: Price Audit Log
**Problem:** Admins have no visibility into the math behind a given price update.
**Plan:**
- The `PriceHistory` table already stores `postsPerHour` alongside `price`. Surface this in two places:
1. **Hashtag detail page** — show the most recent `postsPerHour` reading and the formula result as a tooltip or sub-line under the current price (e.g. _"$1.49 — 5.96 posts/hr at last update"_).
2. **Admin stocks page** — add a "History" drawer/modal per hashtag showing the last N `PriceHistory` rows as a table: `recordedAt | postsPerHour | price | Δ price` so admins can see the full audit trail.
- No schema changes needed — `postsPerHour` is already stored.
---
### 7. Admin: Add Hashtag Manually
**Problem:** Admins cannot bypass the research-point system to seed the exchange with interesting hashtags.
**Plan:**
- "Add hashtag" button on `/admin/stocks` that opens a modal with a tag input.
- API: `POST /api/admin/stocks` — normalizes tag, queries Mastodon immediately (same as research flow), upserts hashtag, queues an initial price-update job. Returns error if Mastodon returns nothing.
- Optionally allow admin to force-add with a manual starting price even if Mastodon has no results (useful for bootstrapping/testing).
---
### 8. Admin: Ban / Block Hashtags
**Problem:** No way to prevent abusive or NSFW hashtags from being researched into the exchange.
**Plan:**
- Add `isBanned: Boolean @default(false)` field to the `Hashtag` model.
- Admin UI: "Ban" button on the stocks page that sets `isBanned = true` and `isActive = false`. Banned hashtags still exist in the DB for record-keeping but are not researchable.
- Research API (`/api/research`): check `isBanned` on any existing matching hashtag — if banned return a `403` with a user-friendly message (e.g. _"This hashtag is not available on HashEx."_) without spending a research point.
- New pre-ban blocklist: a static list (or admin-editable table) of tags that are auto-banned on first research attempt, before any Mastodon query.
- Banned tags should still appear in the admin stocks list with a distinct visual indicator and an "Unban" toggle.
---
### 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.
- **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.
- **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.
-1
View File
@@ -9,7 +9,6 @@
"worker:dev": "tsx watch src/worker/index.ts",
"worker:prod": "tsx src/worker/index.ts",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"postinstall": "prisma generate"
+103 -8
View File
@@ -19,9 +19,60 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
positions Position[]
trades Trade[]
passwordResets PasswordReset[]
isFund Boolean @default(false)
isHidden Boolean @default(false) // hidden from leaderboards and public listings
positions Position[]
trades Trade[]
passwordResets PasswordReset[]
managedFunds FundManager[]
fund HedgeFund?
fundInvestments FundInvestment[]
portfolioHistory UserPortfolioHistory[]
fundApplication FundApplication?
}
model HedgeFund {
id String @id @default(cuid())
name String @unique
slug String @unique // lowercase, URL-safe
userId String @unique // shadow User account that holds positions/trades/balance
user User @relation(fields: [userId], references: [id])
sharesOutstanding Float @default(0) // total fund shares currently in circulation
createdAt DateTime @default(now())
managers FundManager[]
investments FundInvestment[]
navHistory FundNavHistory[]
trades Trade[]
@@index([slug])
}
model FundInvestment {
id String @id @default(cuid())
fundId String
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
shares Float // fund shares held by this investor
avgNavAtBuy Float // NAV per share at time of purchase (for display only)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([fundId, userId])
@@index([userId])
}
model FundManager {
id String @id @default(cuid())
fundId String
fund HedgeFund @relation(fields: [fundId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addedAt DateTime @default(now())
@@unique([fundId, userId])
}
model PasswordReset {
@@ -43,8 +94,10 @@ model Hashtag {
currentPrice Float @default(0.25)
isActive Boolean @default(true)
isBanned Boolean @default(false)
// Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates
// Consecutive zero-result count (informational)
zeroCount Int @default(0)
// Earliest time this hashtag can be deactivated (set on research + when last position closes)
activeUntil DateTime?
lastUpdated DateTime @default(now())
createdAt DateTime @default(now())
@@ -82,6 +135,28 @@ model PriceHistory {
@@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 {
id String @id @default(cuid())
userId String
@@ -103,17 +178,20 @@ model Trade {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashtagId String
hashtag Hashtag @relation(fields: [hashtagId], references: [id])
hashtagId String?
hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
fundId String?
fund HedgeFund? @relation(fields: [fundId], references: [id], onDelete: SetNull)
type TradeType
shares Float
price Float // price per share at time of trade
price Float // price per share at time of trade (or win amount for LOTTERY_WIN)
total Float // cost/proceeds of the trade
profit Float @default(0) // realized P&L (for SELL trades)
profit Float @default(0) // realized P&L (for SELL trades and LOTTERY_WIN)
createdAt DateTime @default(now())
@@index([userId])
@@index([hashtagId])
@@index([fundId])
@@index([createdAt])
}
@@ -127,4 +205,21 @@ enum TradeType {
SELL_LONG
BUY_SHORT
SELL_SHORT
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())
}
+16 -1
View File
@@ -5,6 +5,7 @@ services:
command: sh -c "npx prisma db push --accept-data-loss && npm start"
ports:
- "12131:3000"
- "12555:5555"
environment:
DATABASE_URL: "${DATABASE_URL}"
NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
@@ -12,6 +13,14 @@ services:
REDIS_URL: "${REDIS_URL}"
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
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:
postgres:
condition: service_healthy
@@ -28,8 +37,14 @@ services:
MASTODON_INSTANCE: "${MASTODON_INSTANCE}"
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
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}"
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:
postgres:
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>
)
}
+292
View File
@@ -0,0 +1,292 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
import { Building2, Plus, Trash2, UserPlus, UserMinus } from 'lucide-react'
interface UserSuggestion {
id: string
username: string
displayUsername: string | null
}
interface Manager {
id: string
userId: string
user: { id: string; username: string; displayUsername: string | null }
}
interface Fund {
id: string
name: string
slug: string
user: { balance: number }
managers: Manager[]
}
export default function AdminFundActions({ funds: initialFunds }: { funds: Fund[] }) {
const router = useRouter()
const [funds, setFunds] = useState<Fund[]>(initialFunds)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [newBalance, setNewBalance] = useState('10000')
const [addUsername, setAddUsername] = useState<Record<string, string>>({})
const [suggestions, setSuggestions] = useState<Record<string, UserSuggestion[]>>({})
const [showSuggestions, setShowSuggestions] = useState<Record<string, boolean>>({})
const [expandedId, setExpandedId] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const debounceRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const fetchSuggestions = useCallback(async (fundId: string, q: string) => {
if (!q.trim()) {
setSuggestions((s) => ({ ...s, [fundId]: [] }))
return
}
const res = await fetch(`/api/admin/users?q=${encodeURIComponent(q)}&limit=8`)
if (res.ok) {
const data: UserSuggestion[] = await res.json()
setSuggestions((s) => ({ ...s, [fundId]: data }))
}
}, [])
function handleUsernameChange(fundId: string, value: string) {
setAddUsername((u) => ({ ...u, [fundId]: value }))
setShowSuggestions((s) => ({ ...s, [fundId]: true }))
clearTimeout(debounceRef.current[fundId])
debounceRef.current[fundId] = setTimeout(() => fetchSuggestions(fundId, value), 200)
}
function selectSuggestion(fundId: string, username: string) {
setAddUsername((u) => ({ ...u, [fundId]: username }))
setShowSuggestions((s) => ({ ...s, [fundId]: false }))
setSuggestions((s) => ({ ...s, [fundId]: [] }))
}
async function createFund() {
if (!newName.trim()) return
setLoading(true)
setError('')
const res = await fetch('/api/admin/funds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), initialBalance: parseFloat(newBalance) || 10000 }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setFunds([...funds, data])
setNewName('')
setNewBalance('10000')
setCreating(false)
router.refresh()
}
async function addManager(fundId: string) {
const username = (addUsername[fundId] ?? '').trim()
if (!username) return
setLoading(true)
setError('')
const res = await fetch(`/api/admin/funds/${fundId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addManagerUsername: username }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f))
setAddUsername({ ...addUsername, [fundId]: '' })
}
async function removeManager(fundId: string, userId: string) {
setLoading(true)
const res = await fetch(`/api/admin/funds/${fundId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ removeManagerUserId: userId }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setFunds(funds.map((f) => f.id === fundId ? { ...f, managers: data.managers } : f))
}
async function deleteFund(fundId: string) {
if (!confirm('Delete this fund? This will remove all its positions and trade history.')) return
setLoading(true)
await fetch(`/api/admin/funds/${fundId}`, { method: 'DELETE' })
setLoading(false)
setFunds(funds.filter((f) => f.id !== fundId))
router.refresh()
}
return (
<div className="space-y-4">
{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>
)}
{/* Create form */}
{creating ? (
<div className="bg-surface-card border border-surface-border rounded-xl p-4 space-y-3">
<h3 className="font-medium text-sm">New Hedge Fund</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500 block mb-1">Fund Name</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="TechAlpha Capital"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-1">Initial Balance</label>
<input
type="number"
value={newBalance}
onChange={(e) => setNewBalance(e.target.value)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={createFund}
disabled={loading || !newName.trim()}
className="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg disabled:opacity-50 transition-colors"
>
Create Fund
</button>
<button onClick={() => setCreating(false)} className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-100">
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setCreating(true)}
className="flex items-center gap-2 text-sm px-3 py-1.5 bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" /> New Fund
</button>
)}
{/* Funds list */}
{funds.length === 0 && !creating && (
<p className="text-slate-500 text-sm">No hedge funds yet.</p>
)}
{funds.map((fund) => (
<div key={fund.id} className="bg-surface-card border border-surface-border rounded-xl">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/50 transition-colors rounded-t-xl"
onClick={() => setExpandedId(expandedId === fund.id ? null : fund.id)}
>
<div className="flex items-center gap-3">
<Building2 className="h-4 w-4 text-indigo-400" />
<div>
<p className="font-medium text-sm">{fund.name}</p>
<p className="text-xs text-slate-500">/fund/{fund.slug} · {formatCurrency(fund.user.balance)}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>{fund.managers.length} manager{fund.managers.length !== 1 ? 's' : ''}</span>
<a
href={`/fund/${fund.slug}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-indigo-400 hover:text-indigo-300"
>
View
</a>
<button
onClick={(e) => { e.stopPropagation(); deleteFund(fund.id) }}
disabled={loading}
className="text-red-500 hover:text-red-400 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{expandedId === fund.id && (
<div className="border-t border-surface-border px-4 py-3 space-y-3">
{/* Current managers */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium">Managers</p>
{fund.managers.length === 0 ? (
<p className="text-xs text-slate-600">None yet.</p>
) : (
<div className="flex flex-wrap gap-2">
{fund.managers.map((m) => (
<div key={m.id} className="flex items-center gap-1.5 text-xs bg-surface border border-surface-border rounded-full px-2.5 py-1">
<span>{m.user.displayUsername ?? m.user.username}</span>
<button
onClick={() => removeManager(fund.id, m.userId)}
disabled={loading}
className="text-slate-500 hover:text-red-400 disabled:opacity-50"
>
<UserMinus className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Add manager */}
<div className="flex gap-2">
<div className="relative flex-1">
<input
value={addUsername[fund.id] ?? ''}
onChange={(e) => handleUsernameChange(fund.id, e.target.value)}
placeholder="username"
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addManager(fund.id) }
if (e.key === 'Escape') setShowSuggestions((s) => ({ ...s, [fund.id]: false }))
}}
onFocus={() => {
if ((addUsername[fund.id] ?? '').trim()) {
setShowSuggestions((s) => ({ ...s, [fund.id]: true }))
}
}}
onBlur={() => setTimeout(() => setShowSuggestions((s) => ({ ...s, [fund.id]: false })), 150)}
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{showSuggestions[fund.id] && (suggestions[fund.id] ?? []).length > 0 && (
<div className="absolute z-10 top-full mt-1 left-0 right-0 bg-surface-card border border-surface-border rounded-lg shadow-lg overflow-hidden">
{suggestions[fund.id].map((u) => (
<button
key={u.id}
type="button"
onMouseDown={() => selectSuggestion(fund.id, u.username)}
className="w-full text-left px-3 py-2 text-xs hover:bg-indigo-500/10 transition-colors flex items-center justify-between"
>
<span className="font-medium text-slate-200">{u.username}</span>
{u.displayUsername && u.displayUsername !== u.username && (
<span className="text-slate-500">{u.displayUsername}</span>
)}
</button>
))}
</div>
)}
</div>
<button
onClick={() => addManager(fund.id)}
disabled={loading || !(addUsername[fund.id] ?? '').trim()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600/20 border border-emerald-500/30 hover:bg-emerald-600/30 text-emerald-300 text-xs rounded-lg disabled:opacity-50 transition-colors shrink-0"
>
<UserPlus className="h-3 w-3" /> Add Manager
</button>
</div>
</div>
)}
</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>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { prisma } from '@/lib/prisma'
import AdminFundActions from './AdminFundActions'
import AdminFundApplications from './AdminFundApplications'
export const dynamic = 'force-dynamic'
export default async function AdminFundsPage() {
const [funds, applications] = await Promise.all([
prisma.hedgeFund.findMany({
orderBy: { createdAt: 'asc' },
include: {
user: { select: { balance: true } },
managers: {
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 (
<div className="space-y-8">
<div className="space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
Fund Applications
{applications.length > 0 && (
<span className="text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 rounded-full px-2 py-0.5">
{applications.length}
</span>
)}
</h2>
<AdminFundApplications applications={serialisedApplications} />
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Hedge Funds</h2>
<AdminFundActions funds={funds} />
</div>
</div>
)
}
+1
View File
@@ -20,6 +20,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{ href: '/admin', label: 'Overview' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/stocks', label: 'Stocks' },
{ href: '/admin/funds', label: 'Funds' },
{ href: '/admin/queue', label: 'Queue' },
].map((link) => (
<a
+37 -15
View File
@@ -20,6 +20,7 @@ export default async function AdminOverviewPage() {
},
}),
prisma.user.findMany({
where: { isFund: false, isHidden: false },
orderBy: { balance: 'desc' },
take: 10,
select: { id: true, username: true, balance: true, isAdmin: true },
@@ -66,22 +67,43 @@ export default async function AdminOverviewPage() {
Recent trades
</h2>
<div className="divide-y divide-surface-border">
{recentTrades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span
className={`text-xs px-1.5 py-0.5 rounded ${
t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}
>
{t.type.replace('_', ' ')}
</span>
<span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500">#{t.hashtag.displayTag}</span>
{recentTrades.map((t) => {
const isLottery = t.type === 'LOTTERY_WIN'
const isBuy = t.type.startsWith('BUY')
const isSell = t.type === 'SELL_LONG' || t.type === 'SELL_SHORT'
const isFundTrade = t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM'
const isSystem = t.type === 'ACCOUNT_OPEN' || t.type === 'DONATION' || t.type === 'BANKRUPTCY'
const badgeClass = isLottery
? 'bg-amber-500/15 text-amber-400'
: isFundTrade
? 'bg-indigo-500/15 text-indigo-400'
: isSystem
? 'bg-slate-500/15 text-slate-400'
: isBuy
? 'bg-emerald-500/15 text-emerald-400'
: isSell
? 'bg-red-500/15 text-red-400'
: 'bg-slate-500/15 text-slate-400'
const label = t.hashtag
? `#${t.hashtag.displayTag}`
: isLottery
? 'Lucky Dip'
: isFundTrade
? 'Fund'
: null
return (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${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>
<span>{formatCurrency(t.total)}</span>
</div>
))}
)
})}
</div>
</div>
</div>
+17 -3
View File
@@ -1,5 +1,7 @@
import { priceUpdateQueue, maintenanceQueue, schedulerQueue } from '@/lib/queue'
import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
import { formatDistanceToNow } from 'date-fns'
import RetryFailedButton from '@/components/admin/RetryFailedButton'
import TriggerJobButton from '@/components/admin/TriggerJobButton'
export const dynamic = 'force-dynamic'
@@ -68,13 +70,21 @@ async function getQueueSummary(queue: typeof priceUpdateQueue): Promise<QueueSum
}
export default async function AdminQueuePage() {
const [priceSummary, maintenanceSummary, schedulerSummary] = await Promise.all([
const [priceSummary, maintenanceSummary, schedulerSummary, fundNavSummary] = await Promise.all([
getQueueSummary(priceUpdateQueue),
getQueueSummary(maintenanceQueue),
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 (
<div className="space-y-6">
@@ -89,6 +99,10 @@ export default async function AdminQueuePage() {
<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>
<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} />
<Badge label="waiting" count={q.waiting} color="slate" />
<Badge label="active" count={q.active} color="indigo" />
<Badge label="delayed" count={q.delayed} color="amber" />
+5 -2
View File
@@ -29,7 +29,7 @@ export default async function AdminStocksPage({ searchParams }: Props) {
skip,
take: pageSize,
include: {
_count: { select: { positions: true, trades: true } },
_count: { select: { positions: { where: { shares: { gt: 0 } } }, trades: true } },
},
}),
prisma.hashtag.count({ where }),
@@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) {
{hashtags.map((h) => (
<tr key={h.id} className="hover:bg-surface-hover">
<td className="px-4 py-3">
<a href={`/hashtag/${h.tag}`} className="hover:text-indigo-300">
<a
href={`/hashtag/${h.tag}`}
className={`hover:text-indigo-300 ${!h.isActive && !h.isBanned ? 'text-slate-500' : ''}`}
>
#{h.displayTag}
</a>
</td>
+202 -3
View File
@@ -10,6 +10,7 @@ interface UserData {
balance: number
researchPoints: number
isAdmin: boolean
isHidden: boolean
}
export function AdminUserActions({ user }: { user: UserData }) {
@@ -17,9 +18,18 @@ export function AdminUserActions({ user }: { user: UserData }) {
const [open, setOpen] = useState(false)
const [balance, setBalance] = useState(String(user.balance))
const [points, setPoints] = useState(String(user.researchPoints))
const [hidden, setHidden] = useState(user.isHidden)
const [isAdmin, setIsAdmin] = useState(user.isAdmin)
const [loading, setLoading] = useState(false)
const [resetUrl, setResetUrl] = useState<string | null>(null)
const [lotteryReset, setLotteryReset] = useState(false)
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() {
setLoading(true)
@@ -30,6 +40,8 @@ export function AdminUserActions({ user }: { user: UserData }) {
body: JSON.stringify({
balance: parseFloat(balance),
researchPoints: parseInt(points, 10),
isHidden: hidden,
isAdmin,
}),
})
const data = await res.json()
@@ -57,17 +69,75 @@ export function AdminUserActions({ user }: { user: UserData }) {
}
}
async function handleResetLottery() {
setLoading(true)
setError('')
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resetLotteryAt: true }),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error ?? 'Reset failed.')
} else {
setLotteryReset(true)
}
}
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 (
<>
<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"
>
Edit
</button>
{open && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<div
className="bg-surface-card border border-surface-border rounded-xl p-6 w-full max-w-md space-y-4"
onClick={(e) => e.stopPropagation()}
@@ -95,6 +165,40 @@ export function AdminUserActions({ user }: { user: UserData }) {
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 className="flex items-center justify-between">
<div>
<label className="text-sm text-slate-400">Hidden from leaderboards</label>
<p className="text-xs text-slate-500">Excludes this user from public rankings</p>
</div>
<button
type="button"
onClick={() => setHidden((h) => !h)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
hidden ? 'bg-indigo-600' : 'bg-slate-600'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
hidden ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-slate-400">Admin</label>
<p className="text-xs text-slate-500">Full access to the admin panel</p>
</div>
<button
type="button"
onClick={() => setIsAdmin((a) => !a)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isAdmin ? 'bg-amber-500' : 'bg-slate-600'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isAdmin ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{error && (
@@ -103,7 +207,22 @@ export function AdminUserActions({ user }: { user: UserData }) {
</p>
)}
{/* Reset URL section */}
{/* Lucky Dip reset */}
<div className="border-t border-surface-border pt-4">
<p className="text-sm text-slate-400 mb-2">Lucky Dip</p>
<button
onClick={handleResetLottery}
disabled={loading || lotteryReset}
className="text-sm bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-400 border border-indigo-500/30 px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50"
>
{lotteryReset ? 'Reset ✓' : 'Reset today\'s play'}
</button>
{lotteryReset && (
<p className="text-xs text-slate-500 mt-1">Player can play again today.</p>
)}
</div>
{/* Password reset */}
<div className="border-t border-surface-border pt-4">
<p className="text-sm text-slate-400 mb-2">Password reset</p>
<button
@@ -127,6 +246,86 @@ export function AdminUserActions({ user }: { user: UserData }) {
)}
</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">
<button
onClick={() => setOpen(false)}
+7 -1
View File
@@ -30,6 +30,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
balance: true,
researchPoints: true,
isAdmin: true,
isHidden: true,
createdAt: true,
_count: { select: { trades: true, positions: true } },
},
@@ -73,7 +74,7 @@ export default async function AdminUsersPage({ searchParams }: Props) {
</thead>
<tbody className="divide-y divide-surface-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-surface-hover transition-colors">
<tr key={user.id} className={`hover:bg-surface-hover transition-colors ${user.isHidden ? 'opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<a href={`/profile/${user.username}`} className="hover:text-indigo-300">
@@ -84,6 +85,11 @@ export default async function AdminUsersPage({ searchParams }: Props) {
admin
</span>
)}
{user.isHidden && (
<span className="text-xs bg-slate-500/20 text-slate-400 px-1.5 rounded">
hidden
</span>
)}
</div>
</td>
<td className="px-4 py-3 font-medium">{formatCurrency(user.balance)}</td>
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
function toSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
const approveSchema = z.object({
action: z.literal('approve'),
startingBalance: z.number().min(0).default(0),
})
const denySchema = z.object({
action: z.literal('deny'),
})
/**
* POST /api/admin/fund-applications/[applicationId]
* action: 'approve' — creates the fund, adds applicant as manager, deletes the application
* action: 'deny' — deletes the application
*/
export async function POST(
req: NextRequest,
{ params }: { params: { applicationId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const application = await prisma.fundApplication.findUnique({
where: { id: params.applicationId },
include: { user: { select: { id: true, username: true } } },
})
if (!application) {
return NextResponse.json({ error: 'Application not found' }, { status: 404 })
}
const body = await req.json()
if (body.action === 'deny') {
await prisma.fundApplication.delete({ where: { id: params.applicationId } })
return NextResponse.json({ ok: true })
}
const parsed = approveSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.errors[0]?.message ?? 'Invalid input' }, { status: 400 })
}
const { startingBalance } = parsed.data
const name = application.fundName
const slug = toSlug(name)
const shadowUsername = `fund:${slug}`
// Check for conflicts
const [existingFund, existingSlug, existingUser] = await Promise.all([
prisma.hedgeFund.findFirst({ where: { name: { equals: name, mode: 'insensitive' } } }),
prisma.hedgeFund.findUnique({ where: { slug } }),
prisma.user.findUnique({ where: { username: shadowUsername } }),
])
if (existingFund) return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
if (existingSlug) return NextResponse.json({ error: 'A fund with that slug already exists.' }, { status: 409 })
if (existingUser) return NextResponse.json({ error: 'Shadow user conflict.' }, { status: 409 })
const fund = await prisma.$transaction(async (tx) => {
const shadowUser = await tx.user.create({
data: {
username: shadowUsername,
displayUsername: name,
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10),
balance: startingBalance,
isFund: true,
},
})
const newFund = await tx.hedgeFund.create({
data: { name, slug, userId: shadowUser.id, sharesOutstanding: 0 },
include: {
user: { select: { balance: true } },
managers: { include: { user: { select: { id: true, username: true, displayUsername: true } } } },
},
})
await tx.fundManager.create({
data: { fundId: newFund.id, userId: application.userId },
})
await tx.fundApplication.delete({ where: { id: application.id } })
return { ...newFund, managers: [{ id: 'new', userId: application.userId, user: application.user }] }
})
return NextResponse.json(fund, { status: 201 })
}
+133
View File
@@ -0,0 +1,133 @@
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 { calcFundNav, round2 } from '@/lib/pricing'
const patchSchema = z.object({
addManagerUsername: z.string().optional(),
removeManagerUserId: z.string().optional(),
balance: z.number().min(0).optional(),
})
/**
* PATCH /api/admin/funds/[fundId]
* Add/remove manager, or adjust fund balance.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: { fundId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const fund = await prisma.hedgeFund.findUnique({
where: { id: params.fundId },
include: { user: true },
})
if (!fund) return NextResponse.json({ error: 'Fund not found.' }, { status: 404 })
const body = await req.json().catch(() => null)
const parsed = patchSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
const { addManagerUsername, removeManagerUserId, balance } = parsed.data
if (addManagerUsername) {
const user = await prisma.user.findUnique({
where: { username: addManagerUsername.toLowerCase() },
select: { id: true, isFund: true },
})
if (!user) return NextResponse.json({ error: 'User not found.' }, { status: 404 })
if (user.isFund) return NextResponse.json({ error: 'Cannot add a fund as a manager.' }, { status: 400 })
await prisma.fundManager.upsert({
where: { fundId_userId: { fundId: fund.id, userId: user.id } },
create: { fundId: fund.id, userId: user.id },
update: {},
})
}
if (removeManagerUserId) {
await prisma.fundManager.deleteMany({
where: { fundId: fund.id, userId: removeManagerUserId },
})
}
if (typeof balance === 'number') {
await prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(balance) } })
}
const updated = await prisma.hedgeFund.findUnique({
where: { id: fund.id },
include: {
user: { select: { balance: true } },
managers: {
include: { user: { select: { id: true, username: true, displayUsername: true } } },
orderBy: { addedAt: 'asc' },
},
},
})
return NextResponse.json(updated)
}
/**
* DELETE /api/admin/funds/[fundId]
* Deletes the fund and its shadow user (cascades positions/trades).
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { fundId: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
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 })
// Compute mark-to-market NAV so investors are paid their fair share
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.user.delete({ where: { id: fund.userId } })
return NextResponse.json({ ok: true })
}
+100
View File
@@ -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'
const createSchema = z.object({
name: z.string().min(1).max(60),
initialBalance: z.number().min(0).default(10_000),
})
function toSlug(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
/**
* POST /api/admin/funds
* Body: { name: string, initialBalance?: number }
* Creates a HedgeFund with a shadow User account.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json().catch(() => null)
const parsed = createSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const { name, initialBalance } = parsed.data
const slug = toSlug(name)
if (!slug) {
return NextResponse.json({ error: 'Fund name produces an empty slug.' }, { status: 400 })
}
// Check for existing fund
const existing = await prisma.hedgeFund.findFirst({
where: { OR: [{ name }, { slug }] },
})
if (existing) {
return NextResponse.json({ error: 'A fund with that name already exists.' }, { status: 409 })
}
// Shadow username must also be unique
const shadowUsername = `fund:${slug}`
const existingUser = await prisma.user.findUnique({ where: { username: shadowUsername } })
if (existingUser) {
return NextResponse.json({ error: 'Username conflict — try a different name.' }, { status: 409 })
}
// Create shadow user + fund in a transaction
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), // random, non-loginable
balance: initialBalance,
researchPoints: 0,
isFund: true,
},
})
return tx.hedgeFund.create({
data: { name, slug, userId: shadowUser.id, sharesOutstanding: initialBalance },
include: { user: { select: { balance: true } }, managers: true },
})
})
return NextResponse.json(fund, { status: 201 })
}
/**
* GET /api/admin/funds
* Returns all funds with their managers.
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const funds = await prisma.hedgeFund.findMany({
orderBy: { createdAt: 'asc' },
include: {
user: { select: { balance: true } },
managers: {
include: { user: { select: { id: true, username: true, displayUsername: true } } },
orderBy: { addedAt: 'asc' },
},
},
})
return NextResponse.json(funds)
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { priceUpdateQueue, maintenanceQueue, schedulerQueue, fundNavSnapshotQueue } from '@/lib/queue'
import { Queue } from 'bullmq'
const QUEUES: Record<string, Queue> = {
'hashex-price-updates': priceUpdateQueue,
'hashex-maintenance': maintenanceQueue,
'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(
req: NextRequest,
{ params }: { params: { name: string } }
) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const queue = QUEUES[params.name]
if (!queue) {
return NextResponse.json({ error: 'Queue not found' }, { status: 404 })
}
const { action } = await req.json() as { action: string }
if (action === 'retry-failed') {
await queue.retryJobs({ count: 100, state: 'failed' })
return NextResponse.json({ ok: true })
}
if (action === 'clean-failed') {
await queue.clean(0, 100, 'failed')
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 })
}
@@ -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 })
}
+79 -2
View File
@@ -3,11 +3,14 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
import { calcFundNav, round2 } from '@/lib/pricing'
const schema = z.object({
balance: z.number().min(0).optional(),
researchPoints: z.number().int().min(0).optional(),
isAdmin: z.boolean().optional(),
isHidden: z.boolean().optional(),
resetLotteryAt: z.boolean().optional(),
})
export async function PATCH(req: NextRequest, { params }: { params: { userId: string } }) {
@@ -22,11 +25,85 @@ export async function PATCH(req: NextRequest, { params }: { params: { userId: st
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const { resetLotteryAt, ...rest } = parsed.data
const updated = await prisma.user.update({
where: { id: params.userId },
data: parsed.data,
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true },
data: {
...rest,
...(resetLotteryAt ? { lastLotteryAt: null } : {}),
},
select: { id: true, username: true, balance: true, researchPoints: true, isAdmin: true, isHidden: true },
})
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 })
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
/**
* GET /api/admin/users?q=<search>&limit=<n>
* Returns users matching the query (excludes fund shadow accounts).
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = req.nextUrl
const q = (searchParams.get('q') ?? '').trim().toLowerCase()
const limit = Math.min(10, Math.max(1, parseInt(searchParams.get('limit') ?? '8', 10)))
if (!q) return NextResponse.json([])
const users = await prisma.user.findMany({
where: {
isFund: false,
username: { contains: q },
},
select: { id: true, username: true, displayUsername: true },
orderBy: { username: 'asc' },
take: limit,
})
return NextResponse.json(users)
}
+12 -1
View File
@@ -36,9 +36,20 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12)
await prisma.user.create({
const user = await prisma.user.create({
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 })
}
+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 })
}
+93
View File
@@ -0,0 +1,93 @@
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'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const slug = decodeURIComponent(params.slug).toLowerCase()
const body = await req.json()
const amount = round2(Number(body.amount))
if (!amount || amount < 1) {
return NextResponse.json({ error: 'Minimum investment is $1' }, { status: 400 })
}
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
include: {
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 })
// Verify investor is not the fund's own shadow user
const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } })
if (!investor || investor.isFund) {
return NextResponse.json({ error: 'Invalid account' }, { status: 403 })
}
if (investor.balance < amount) {
return NextResponse.json({ error: 'Insufficient balance' }, { status: 400 })
}
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 totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const sharesToMint = Math.round((amount / nav) * 1e6) / 1e6
// Weighted average NAV at buy for display
const existingInvestment = await prisma.fundInvestment.findUnique({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
select: { shares: true, avgNavAtBuy: true },
})
let newAvgNav: number
if (existingInvestment) {
const totalShares = existingInvestment.shares + sharesToMint
newAvgNav = (existingInvestment.avgNavAtBuy * existingInvestment.shares + nav * sharesToMint) / totalShares
} else {
newAvgNav = nav
}
const [updatedInvestor] = await prisma.$transaction([
// Deduct from investor (returns updated user with new balance)
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance - amount) } }),
// Add to fund's cash
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance + amount) } }),
// Upsert FundInvestment record
prisma.fundInvestment.upsert({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
create: { fundId: fund.id, userId: session.user.id, shares: sharesToMint, avgNavAtBuy: newAvgNav },
update: { shares: { increment: sharesToMint }, avgNavAtBuy: newAvgNav },
}),
// Increment fund shares outstanding
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({
shares: sharesToMint,
nav,
newBalance: updatedInvestor.balance,
})
}
+92
View File
@@ -0,0 +1,92 @@
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'
export async function POST(req: NextRequest, { params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const slug = decodeURIComponent(params.slug).toLowerCase()
const body = await req.json()
const sharesToRedeem = Number(body.shares)
if (!sharesToRedeem || sharesToRedeem <= 0) {
return NextResponse.json({ error: 'Invalid share amount' }, { status: 400 })
}
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
include: {
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 })
const investment = await prisma.fundInvestment.findUnique({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
select: { shares: true, avgNavAtBuy: true },
})
if (!investment || investment.shares < sharesToRedeem) {
return NextResponse.json({ error: 'Insufficient fund shares' }, { status: 400 })
}
const investor = await prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true } })
if (!investor) return NextResponse.json({ error: 'User not found' }, { status: 404 })
const portfolioValue = fund.user.positions.reduce((sum, p) => {
const val = p.positionType === 'LONG'
? p.shares * p.hashtag.currentPrice
: (2 * p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + val
}, 0)
const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const payout = round2(sharesToRedeem * nav)
if (fund.user.balance < payout) {
return NextResponse.json({ error: 'Fund has insufficient cash to redeem. Try a smaller amount.' }, { status: 400 })
}
const remainingShares = Math.round((investment.shares - sharesToRedeem) * 1e6) / 1e6
const profit = round2(payout - sharesToRedeem * investment.avgNavAtBuy)
const [updatedInvestor] = await prisma.$transaction([
// Return cash to investor
prisma.user.update({ where: { id: session.user.id }, data: { balance: round2(investor.balance + payout) } }),
// Deduct from fund's cash
prisma.user.update({ where: { id: fund.userId }, data: { balance: round2(fund.user.balance - payout) } }),
// Update or delete FundInvestment
...(remainingShares > 0
? [prisma.fundInvestment.update({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
data: { shares: remainingShares },
})]
: [prisma.fundInvestment.delete({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
})]),
// Decrement fund shares outstanding
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({
payout,
nav,
newBalance: updatedInvestor.balance,
})
}
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim().replace(/^#/, '').toLowerCase()
if (!q || q.length < 1) return NextResponse.json([])
const results = await prisma.hashtag.findMany({
where: {
isActive: true,
isBanned: false,
tag: { startsWith: q },
},
orderBy: { currentPrice: 'desc' },
take: 8,
select: { tag: true, displayTag: true, currentPrice: true },
})
return NextResponse.json(results)
}
+28 -11
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { round2 } from '@/lib/pricing'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
function buildPrizes(): number[] {
@@ -15,9 +16,9 @@ function buildPrizes(): number[] {
function isSameDay(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
@@ -25,7 +26,7 @@ function isSameDay(a: Date, b: Date) {
* POST /api/lottery/pick
* Body: { box: number } (0-indexed, 024)
*
* One free play per calendar day (UTC). Reveals prize at the chosen box.
* One free play per calendar day (Eastern Time). Reveals prize at the chosen box.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
@@ -64,13 +65,29 @@ export async function POST(req: NextRequest) {
const winAmount = prizes[box]
await prisma.user.update({
where: { id: user.id },
data: {
balance: { increment: winAmount },
lastLotteryAt: now,
},
})
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: {
balance: { increment: round2(winAmount) },
lastLotteryAt: now,
},
}),
...(winAmount > 0
? [
prisma.trade.create({
data: {
userId: user.id,
type: 'LOTTERY_WIN',
shares: 1,
price: winAmount,
total: winAmount,
profit: winAmount,
},
}),
]
: []),
])
return NextResponse.json({
box,
+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 },
)
}
+11 -4
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getPostsPerHour } from '@/lib/mastodon'
import { getPostsData } from '@/lib/mastodon'
import { calcPrice } from '@/lib/pricing'
import { normalizeTag } from '@/lib/utils'
import { priceUpdateQueue } from '@/lib/queue'
@@ -49,8 +49,11 @@ export async function POST(req: NextRequest) {
// Query Mastodon
let postsPerHour = 0
let hasAnyPosts = false
try {
postsPerHour = await getPostsPerHour(tag)
const data = await getPostsData(tag)
postsPerHour = data.postsPerHour
hasAnyPosts = data.hasAnyPosts
} catch (err) {
console.error('[research] Mastodon fetch failed:', err)
return NextResponse.json(
@@ -59,19 +62,21 @@ export async function POST(req: NextRequest) {
)
}
if (postsPerHour === 0) {
if (!hasAnyPosts) {
// Deduct point for failed research
await prisma.user.update({
where: { id: session.user.id },
data: { researchPoints: { decrement: 1 } },
})
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 },
)
}
// Use the last-hour price, or $0.25 minimum if active but currently quiet
const price = calcPrice(postsPerHour)
const activeUntil = new Date(Date.now() + parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10) * 60 * 60 * 1000)
// Upsert the hashtag and deduct point atomically
const [hashtag] = await prisma.$transaction([
@@ -82,6 +87,7 @@ export async function POST(req: NextRequest) {
displayTag: raw.trim().replace(/^#+/, ''),
currentPrice: price,
isActive: true,
activeUntil,
priceHistory: {
create: { price, postsPerHour },
},
@@ -90,6 +96,7 @@ export async function POST(req: NextRequest) {
isActive: true,
currentPrice: price,
zeroCount: 0,
activeUntil,
lastUpdated: new Date(),
priceHistory: {
create: { price, postsPerHour },
+58 -5
View File
@@ -2,13 +2,20 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
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'
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({
hashtagId: z.string().min(1),
type: z.enum(['BUY_LONG', 'SELL_LONG', 'BUY_SHORT', 'SELL_SHORT']),
shares: z.number().positive().max(1_000_000),
fundId: z.string().optional(), // if set: trade on behalf of this fund
})
/**
@@ -24,12 +31,24 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
}
const { hashtagId, type, shares } = parsed.data
const { hashtagId, type, shares, fundId } = parsed.data
// Fetch hashtag and user together
// Fetch hashtag and determine which user account to trade as
let actorId = session.user.id
if (fundId) {
const membership = await prisma.fundManager.findUnique({
where: { fundId_userId: { fundId, userId: session.user.id } },
include: { fund: { select: { userId: true } } },
})
if (!membership) {
return NextResponse.json({ error: 'You are not a manager of this fund.' }, { status: 403 })
}
actorId = membership.fund.userId
}
// Fetch hashtag and actor account together
const [hashtag, user] = await Promise.all([
prisma.hashtag.findUnique({ where: { id: hashtagId, isActive: true } }),
prisma.user.findUnique({ where: { id: session.user.id } }),
prisma.user.findUnique({ where: { id: actorId } }),
])
if (!hashtag) return NextResponse.json({ error: 'Hashtag not found or inactive.' }, { status: 404 })
@@ -50,6 +69,25 @@ export async function POST(req: NextRequest) {
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 (!existingPosition || existingPosition.shares < shares) {
return NextResponse.json({ error: 'Insufficient shares to sell.' }, { status: 400 })
@@ -67,7 +105,7 @@ export async function POST(req: NextRequest) {
// Update user balance
await tx.user.update({
where: { id: user.id },
data: { balance: { increment: balanceDelta } },
data: { balance: { increment: round2(balanceDelta) } },
})
// Update / create position
@@ -116,5 +154,20 @@ export async function POST(req: NextRequest) {
})
})
// When a sell closes the last position on this hashtag globally, extend the active window
if (type === 'SELL_LONG' || type === 'SELL_SHORT') {
const newShares = (existingPosition?.shares ?? 0) - shares
if (newShares <= 0) {
const remaining = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
if (remaining === 0) {
const hours = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
await prisma.hashtag.update({
where: { id: hashtagId },
data: { activeUntil: new Date(Date.now() + hours * 60 * 60 * 1000) },
})
}
}
}
return NextResponse.json({ ok: true })
}
+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 { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { calcFundNav, round2 } from '@/lib/pricing'
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 })
}
/**
* 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 })
}
+194
View File
@@ -0,0 +1,194 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
interface Props {
fundSlug: string
nav: number
userBalance: number
userShares: number
userAvgNav: number
}
export default function InvestPanel({ fundSlug, nav, userBalance, userShares, userAvgNav }: Props) {
const router = useRouter()
const [tab, setTab] = useState<'invest' | 'redeem'>('invest')
const [amount, setAmount] = useState('')
const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const amountNum = parseFloat(amount) || 0
const sharesNum = parseFloat(shares) || 0
const previewShares = nav > 0 ? amountNum / nav : 0
const previewPayout = sharesNum * nav
async function handleInvest(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess('')
if (amountNum < 1) { setError('Minimum $1'); return }
setLoading(true)
try {
const res = await fetch(`/api/funds/${fundSlug}/invest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: amountNum }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setSuccess(`Invested! You received ${data.shares.toFixed(4)} shares at NAV ${formatCurrency(data.nav)}.`)
setAmount('')
router.refresh()
} finally {
setLoading(false)
}
}
async function handleRedeem(e: React.FormEvent) {
e.preventDefault()
setError('')
setSuccess('')
if (sharesNum <= 0) { setError('Enter shares to redeem'); return }
setLoading(true)
try {
const res = await fetch(`/api/funds/${fundSlug}/redeem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shares: sharesNum }),
})
const data = await res.json()
if (!res.ok) { setError(data.error ?? 'Failed'); return }
setSuccess(`Redeemed ${sharesNum.toFixed(4)} shares for ${formatCurrency(data.payout)}.`)
setShares('')
router.refresh()
} finally {
setLoading(false)
}
}
return (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-surface-border">
{(['invest', 'redeem'] as const).map((t) => (
<button
key={t}
onClick={() => { setTab(t); setError(''); setSuccess('') }}
className={`flex-1 py-3 text-sm font-medium capitalize transition-colors ${
tab === t
? 'text-indigo-300 border-b-2 border-indigo-400 bg-indigo-500/5'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t}
</button>
))}
</div>
<div className="p-5 space-y-4">
{/* NAV info */}
<div className="flex items-center justify-between text-sm">
<span className="text-slate-400">Current NAV / share</span>
<span className="font-semibold">{formatCurrency(nav)}</span>
</div>
{/* Holdings summary */}
{userShares > 0 && (
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-lg px-4 py-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-slate-400">Your shares</span>
<span className="font-medium">{userShares.toFixed(4)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Current value</span>
<span className="font-medium">{formatCurrency(userShares * nav)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">Avg buy NAV</span>
<span className="font-medium">{formatCurrency(userAvgNav)}</span>
</div>
</div>
)}
{tab === 'invest' ? (
<form onSubmit={handleInvest} className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">Amount to invest</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">$</span>
<input
type="number"
min="1"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
className="w-full bg-surface border border-surface-border rounded-lg pl-7 pr-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<p className="text-xs text-slate-500 mt-1">
Balance: {formatCurrency(userBalance)}
</p>
</div>
{amountNum >= 1 && (
<p className="text-xs text-slate-400">
You&apos;ll receive <span className="text-white font-medium">{previewShares.toFixed(6)} shares</span>
</p>
)}
<button
type="submit"
disabled={loading || amountNum < 1}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
>
{loading ? 'Investing…' : 'Invest'}
</button>
</form>
) : (
<form onSubmit={handleRedeem} className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">Shares to redeem</label>
<input
type="number"
min="0"
step="0.000001"
max={userShares}
value={shares}
onChange={(e) => setShares(e.target.value)}
placeholder="0.0000"
className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500"
/>
{userShares > 0 && (
<button
type="button"
onClick={() => setShares(String(userShares))}
className="text-xs text-indigo-400 hover:text-indigo-300 mt-1"
>
Max ({userShares.toFixed(6)})
</button>
)}
</div>
{sharesNum > 0 && (
<p className="text-xs text-slate-400">
You&apos;ll receive <span className="text-white font-medium">{formatCurrency(previewPayout)}</span>
</p>
)}
<button
type="submit"
disabled={loading || sharesNum <= 0 || sharesNum > userShares}
className="w-full py-2.5 rounded-lg bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors"
>
{loading ? 'Redeeming…' : 'Redeem'}
</button>
</form>
)}
{error && <p className="text-red-400 text-sm">{error}</p>}
{success && <p className="text-emerald-400 text-sm">{success}</p>}
</div>
</div>
)
}
+294
View File
@@ -0,0 +1,294 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation'
import { formatCurrency, formatPnl, pnlColor } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import Link from 'next/link'
import { Building2, TrendingUp, TrendingDown } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
import { calcFundNav } from '@/lib/pricing'
import InvestPanel from './InvestPanel'
import { PriceChart } from '@/components/PriceChart'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const slug = decodeURIComponent(params.slug).toLowerCase()
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
select: { name: true },
})
const name = fund?.name ?? slug
const title = `${name} — HashEx Hedge Fund`
const description = `${name} is a hedge fund on HashEx trading Mastodon hashtags. View their portfolio, NAV, and performance.`
const imageUrl = `/api/og/fund/${encodeURIComponent(slug)}`
return {
title,
description,
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${name} fund overview` }] },
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
}
}
export default async function FundPage({ params }: { params: { slug: string } }) {
const session = await getServerSession(authOptions)
const slug = decodeURIComponent(params.slug).toLowerCase()
const fund = await prisma.hedgeFund.findUnique({
where: { slug },
include: {
user: {
select: {
balance: true,
positions: {
where: { shares: { gt: 0 } },
include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true, isActive: true } } },
},
trades: {
orderBy: { createdAt: 'desc' },
take: 30,
include: { hashtag: { select: { tag: true, displayTag: true } } },
},
},
},
managers: {
include: { user: { select: { id: true, username: true, displayUsername: true } } },
orderBy: { addedAt: 'asc' },
},
_count: { select: { investments: true } },
},
})
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
const [currentUser, userInvestment] = session
? await Promise.all([
prisma.user.findUnique({ where: { id: session.user.id }, select: { balance: true, isFund: true } }),
prisma.fundInvestment.findUnique({
where: { fundId_userId: { fundId: fund.id, userId: session.user.id } },
select: { shares: true, avgNavAtBuy: true },
}),
])
: [null, null]
const isManager = session
? fund.managers.some((m) => m.userId === session.user.id)
: false
const positions = fund.user.positions
const portfolioValue = 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 unrealizedPnl = positions.reduce((sum, p) => {
const pnl = p.positionType === 'LONG'
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return sum + pnl
}, 0)
const totalValue = fund.user.balance + portfolioValue
const nav = calcFundNav(totalValue, fund.sharesOutstanding)
const canInvest = !!session && !!currentUser && !currentUser.isFund
return (
<div className="max-w-4xl mx-auto space-y-8">
<AutoRefresh intervalMs={30_000} />
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-6 w-6 text-indigo-400" />
<h1 className="text-3xl font-bold">{fund.name}</h1>
</div>
<p className="text-slate-500 text-sm">Hedge Fund</p>
{isManager && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
You are a manager of this fund
</span>
)}
</div>
<div className="text-right">
<p className="text-3xl font-bold">{formatCurrency(totalValue)}</p>
<p className="text-sm text-slate-400">total fund value</p>
</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 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{[
{ label: 'Cash', value: formatCurrency(fund.user.balance) },
{ label: 'Invested', value: formatCurrency(portfolioValue) },
{ label: 'Unrealized P&L', value: formatPnl(unrealizedPnl), colorClass: pnlColor(unrealizedPnl) },
{ label: 'NAV / Share', value: formatCurrency(nav) },
{ label: 'Shares Out.', value: fund.sharesOutstanding.toFixed(2) },
{ label: 'Investors', value: String(fund._count.investments) },
].map(({ label, value, colorClass }) => (
<div key={label} className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${colorClass ?? ''}`}>{value}</p>
</div>
))}
</div>
{/* Manager trading panel */}
{isManager && (
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-xl p-5">
<h2 className="font-semibold mb-1 flex items-center gap-2">
<Building2 className="h-4 w-4 text-indigo-400" />
Trade on behalf of this fund
</h2>
<p className="text-sm text-slate-400 mb-4">
Search for a hashtag below and trade using the fund&apos;s balance.
All positions and profit belong to the fund.
</p>
<div className="flex flex-wrap gap-2">
{positions.map((p) => (
<Link
key={p.id}
href={`/hashtag/${p.hashtag.tag}?fund=${fund.slug}`}
className="text-xs bg-surface border border-surface-border hover:border-indigo-500/50 text-slate-300 hover:text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
>
#{p.hashtag.displayTag}
</Link>
))}
<Link
href={`/stocks?fund=${fund.slug}`}
className="text-xs bg-indigo-600/20 border border-indigo-500/30 hover:bg-indigo-600/30 text-indigo-300 px-3 py-1.5 rounded-full transition-colors"
>
Browse all stocks
</Link>
</div>
</div>
)}
{/* Invest / Redeem panel */}
{canInvest && (
<div>
<h2 className="font-semibold mb-3 flex items-center gap-2 text-sm text-slate-400 uppercase tracking-wider">
Invest in this Fund
</h2>
<InvestPanel
fundSlug={fund.slug}
nav={nav}
userBalance={currentUser!.balance}
userShares={userInvestment?.shares ?? 0}
userAvgNav={userInvestment?.avgNavAtBuy ?? 0}
/>
</div>
)}
{/* Open positions */}
{positions.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Open Positions
</h2>
<div className="divide-y divide-surface-border">
{positions.map((p) => {
const pnl = p.positionType === 'LONG'
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares
return (
<div key={p.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<Link
href={isManager ? `/hashtag/${p.hashtag.tag}?fund=${fund.slug}` : `/hashtag/${p.hashtag.tag}`}
className="font-medium text-indigo-300 hover:text-indigo-200"
>
#{p.hashtag.displayTag}
</Link>
<span className={`text-xs px-1.5 py-0.5 rounded ${p.positionType === 'LONG' ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{p.positionType}
</span>
</div>
<div className="text-right">
<p className="font-medium">{formatCurrency(p.shares * p.hashtag.currentPrice)}</p>
<p className={`text-xs ${pnlColor(pnl)}`}>{formatPnl(pnl)}</p>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Managers */}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Managers
</h2>
{fund.managers.length === 0 ? (
<p className="text-slate-500 text-sm px-4 py-3">No managers assigned yet.</p>
) : (
<div className="divide-y divide-surface-border">
{fund.managers.map((m) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3 text-sm">
<Link href={`/profile/${m.user.username}`} className="text-slate-300 hover:text-white">
{m.user.displayUsername ?? m.user.username}
</Link>
<span className="text-xs text-slate-500">
since {formatDistanceToNow(new Date(m.addedAt), { addSuffix: true })}
</span>
</div>
))}
</div>
)}
</div>
{/* Recent trades */}
{fund.user.trades.length > 0 && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<h2 className="text-sm font-medium text-slate-400 px-4 py-3 border-b border-surface-border">
Recent Trades
</h2>
<div className="divide-y divide-surface-border">
{fund.user.trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'}`}>
{t.type.replace('_', ' ')}
</span>
{t.hashtag && (
<Link href={`/hashtag/${t.hashtag.tag}`} className="text-slate-300 hover:text-white">
#{t.hashtag.displayTag}
</Link>
)}
</div>
<div className="text-right text-xs text-slate-400">
<p>{formatCurrency(t.total)}</p>
<p>{formatDistanceToNow(new Date(t.createdAt), { addSuffix: true })}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -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>
)
}
+102 -20
View File
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { formatCurrency, formatNumber } from '@/lib/utils'
interface Props {
@@ -9,26 +10,39 @@ interface Props {
balance: number
longPosition: { shares: number; avgBuyPrice: number } | null
shortPosition: { shares: number; avgBuyPrice: number } | null
fundId?: string
fundName?: string
managedFunds?: { slug: string; name: string }[]
maxPositionShares: number
maxPositionValue: number
}
type Tab = 'BUY_LONG' | 'SELL_LONG' | 'BUY_SHORT' | 'SELL_SHORT'
export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Props) {
export function TradePanel({ hashtag, balance, longPosition, shortPosition, fundId, fundName, managedFunds, maxPositionShares, maxPositionValue }: Props) {
const router = useRouter()
const [tab, setTab] = useState<Tab>('BUY_LONG')
const [shares, setShares] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showFundMenu, setShowFundMenu] = useState(false)
const sharesNum = parseFloat(shares) || 0
const cost = sharesNum * hashtag.currentPrice
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 =
tab === 'SELL_LONG' ? longPosition?.shares ?? 0 : shortPosition?.shares ?? 0
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() {
if (sharesNum <= 0) return
@@ -38,7 +52,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
const res = await fetch('/api/trade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum }),
body: JSON.stringify({ hashtagId: hashtag.id, type: tab, shares: sharesNum, ...(fundId ? { fundId } : {}) }),
})
const data = await res.json()
@@ -54,10 +68,57 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
return (
<div className="bg-surface-card border border-surface-border rounded-xl p-6 space-y-5">
{fundName ? (
<div className="flex items-center gap-2 text-xs bg-indigo-500/10 border border-indigo-500/30 rounded-lg px-3 py-2 text-indigo-300">
<span className="text-base">🏦</span>
<span>Trading as <span className="font-semibold">{fundName}</span></span>
<Link
href={`/hashtag/${hashtag.tag}`}
className="ml-auto text-indigo-400 hover:text-indigo-200 transition-colors"
>
Exit fund mode ×
</Link>
</div>
) : managedFunds && managedFunds.length === 1 ? (
<Link
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(managedFunds[0].slug)}`}
className="flex items-center gap-2 text-xs border border-surface-border rounded-lg px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as <span className="font-medium text-slate-200">{managedFunds[0].name}</span></span>
<span className="ml-auto"></span>
</Link>
) : managedFunds && managedFunds.length > 1 ? (
<div className="text-xs border border-surface-border rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowFundMenu((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 hover:bg-surface transition-colors"
>
<span className="text-base">🏦</span>
<span>Trade as a fund</span>
<span className="ml-auto text-slate-600">{showFundMenu ? '▲' : '▼'}</span>
</button>
{showFundMenu && (
<div className="border-t border-surface-border divide-y divide-surface-border">
{managedFunds.map((f) => (
<Link
key={f.slug}
href={`/hashtag/${hashtag.tag}?fund=${encodeURIComponent(f.slug)}`}
className="flex items-center justify-between px-3 py-2 font-medium text-indigo-300 hover:text-indigo-200 hover:bg-surface transition-colors"
>
<span>{f.name}</span>
<span className="text-slate-500"></span>
</Link>
))}
</div>
)}
</div>
) : null}
<div className="flex items-center justify-between">
<h2 className="font-semibold">Trade #{hashtag.displayTag}</h2>
<span className="text-sm text-slate-400">
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>
</div>
@@ -67,7 +128,7 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
<button
key={t}
onClick={() => { setTab(t); setShares(''); setError('') }}
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors ${
className={`flex-1 text-xs py-1.5 rounded-md font-medium transition-colors leading-tight ${
tab === t
? t.startsWith('BUY')
? 'bg-emerald-600 text-white'
@@ -75,7 +136,9 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
: 'text-slate-400 hover:text-slate-200'
}`}
>
{t.replace('_', ' ')}
<span className="block sm:inline">{t.split('_')[0]}</span>
<span className="hidden sm:inline"> </span>
<span className="block sm:inline">{t.split('_')[1]}</span>
</button>
))}
</div>
@@ -84,23 +147,35 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">LONG position</p>
{longPosition ? (
<>
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
</>
) : (
{longPosition ? (() => {
const pnl = (hashtag.currentPrice - longPosition.avgBuyPrice) * longPosition.shares
return (
<>
<p className="font-medium">{formatNumber(longPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(longPosition.avgBuyPrice)}</p>
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
</p>
</>
)
})() : (
<p className="text-slate-600">None</p>
)}
</div>
<div className="bg-surface rounded-lg p-3">
<p className="text-slate-500 text-xs mb-1">SHORT position</p>
{shortPosition ? (
<>
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
</>
) : (
{shortPosition ? (() => {
const pnl = (shortPosition.avgBuyPrice - hashtag.currentPrice) * shortPosition.shares
return (
<>
<p className="font-medium">{formatNumber(shortPosition.shares)} shares</p>
<p className="text-slate-400 text-xs">avg {formatCurrency(shortPosition.avgBuyPrice)}</p>
<p className={`text-xs font-medium mt-1 ${pnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pnl >= 0 ? '+' : ''}{formatCurrency(pnl)}
</p>
</>
)
})() : (
<p className="text-slate-600">None</p>
)}
</div>
@@ -119,7 +194,14 @@ export function TradePanel({ hashtag, balance, longPosition, shortPosition }: Pr
: 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>
</div>
<input
+148 -24
View File
@@ -6,30 +6,87 @@ import { formatCurrency, formatNumber } from '@/lib/utils'
import { PriceChart } from '@/components/PriceChart'
import { TradePanel } from './TradePanel'
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 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'
interface Props {
params: { tag: string }
searchParams: { fund?: string }
}
export default async function HashtagPage({ params }: Props) {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const hashtag = await prisma.hashtag.findUnique({
where: { tag },
select: {
displayTag: true,
currentPrice: true,
isActive: true,
priceHistory: { orderBy: { recordedAt: 'desc' }, take: 2, select: { price: true } },
},
})
const displayTag = hashtag?.displayTag ?? tag
const price = hashtag?.currentPrice ?? 0
const prevPrice = hashtag?.priceHistory[1]?.price ?? null
const changePct = prevPrice && prevPrice > 0
? ((price - prevPrice) / prevPrice) * 100
: null
const priceStr = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 }).format(price)
const changeStr = changePct !== null
? ` ${changePct >= 0 ? '▲' : '▼'} ${Math.abs(changePct).toFixed(2)}%`
: ''
const status = hashtag?.isActive === false ? ' · inactive' : ''
const title = `#${displayTag}${priceStr}${changeStr}`
const description = `Trade #${displayTag} on HashEx. Current price: ${priceStr}${changeStr}${status}. Prices driven by real Mastodon activity.`
const imageUrl = `/api/og/hashtag/${encodeURIComponent(tag)}`
return {
title,
description,
openGraph: {
title,
description,
images: [{ url: imageUrl, width: 1200, height: 630, alt: `#${displayTag} price chart` }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [imageUrl],
},
}
}
export default async function HashtagPage({ params, searchParams }: Props) {
const session = await getServerSession(authOptions)
const tag = decodeURIComponent(params.tag).toLowerCase().replace(/^#+/, '')
const fundSlug = searchParams.fund
const [hashtag, userBalance, userPosition] = await Promise.all([
prisma.hashtag.findUnique({
where: { tag },
include: {
priceHistory: {
orderBy: { recordedAt: 'asc' },
take: 200,
orderBy: { recordedAt: 'desc' },
take: 192, // 192 updates = 2 days of 15-min intervals
},
_count: {
select: { positions: true },
select: { positions: { where: { shares: { gt: 0 } } } },
},
relatedFrom: {
orderBy: { coOccurrences: 'desc' },
@@ -51,6 +108,39 @@ export default async function HashtagPage({ params }: Props) {
: [],
])
// Resolve fund context if ?fund= is provided
let fundContext: { id: string; name: string; balance: number; positions: { hashtagId: string; positionType: string; shares: number; avgBuyPrice: number }[] } | null = null
if (fundSlug && session) {
const fund = await prisma.hedgeFund.findUnique({
where: { slug: fundSlug },
include: {
user: { select: { balance: true } },
managers: { where: { userId: session.user.id }, select: { userId: true } },
},
})
if (fund && fund.managers.length > 0) {
const fundPositions = await prisma.position.findMany({
where: { userId: fund.userId },
})
fundContext = {
id: fund.id,
name: fund.name,
balance: fund.user.balance,
positions: fundPositions,
}
}
}
// When not in fund mode, fetch funds this user manages for the fund-mode switcher
let managedFunds: { slug: string; name: string }[] = []
if (session && !fundContext) {
const managerships = await prisma.fundManager.findMany({
where: { userId: session.user.id },
include: { fund: { select: { slug: true, name: true } } },
})
managedFunds = managerships.map((m) => ({ slug: m.fund.slug, name: m.fund.name }))
}
// Unknown hashtag — show research panel
if (!hashtag || !hashtag.isActive) {
return (
@@ -87,8 +177,18 @@ export default async function HashtagPage({ params }: Props) {
const longPosition = positions.find((p) => p.positionType === 'LONG')
const shortPosition = positions.find((p) => p.positionType === 'SHORT')
// When trading as a fund, show the fund's positions instead
const activeLong = fundContext
? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'LONG' && p.shares > 0) ?? null
: longPosition ?? null
const activeShort = fundContext
? fundContext.positions.find((p) => p.hashtagId === hashtag.id && p.positionType === 'SHORT' && p.shares > 0) ?? null
: shortPosition ?? null
const activeBalance = fundContext ? fundContext.balance : (userBalance?.balance ?? 0)
return (
<div className="space-y-8">
<AutoRefresh intervalMs={30_000} />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
@@ -111,11 +211,35 @@ export default async function HashtagPage({ params }: Props) {
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<h2 className="text-sm font-medium text-slate-400 mb-4">Price History</h2>
<PriceChart
data={hashtag.priceHistory.map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
data={hashtag.priceHistory.slice().reverse().map((p) => ({ ...p, recordedAt: p.recordedAt.toISOString() }))}
height={280}
/>
</div>
{/* 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 */}
{session ? (
<TradePanel
@@ -125,17 +249,14 @@ export default async function HashtagPage({ params }: Props) {
displayTag: hashtag.displayTag,
currentPrice: hashtag.currentPrice,
}}
balance={userBalance?.balance ?? 0}
longPosition={
longPosition
? { shares: longPosition.shares, avgBuyPrice: longPosition.avgBuyPrice }
: null
}
shortPosition={
shortPosition
? { shares: shortPosition.shares, avgBuyPrice: shortPosition.avgBuyPrice }
: null
}
balance={activeBalance}
longPosition={activeLong ? { shares: activeLong.shares, avgBuyPrice: activeLong.avgBuyPrice } : null}
shortPosition={activeShort ? { shares: activeShort.shares, avgBuyPrice: activeShort.avgBuyPrice } : null}
fundId={fundContext?.id}
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">
@@ -181,7 +302,7 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
where: { hashtagId },
orderBy: { createdAt: 'desc' },
take: 20,
include: { user: { select: { username: true } } },
include: { user: { select: { username: true, displayUsername: true, isFund: true } } },
})
if (trades.length === 0) return null
@@ -202,12 +323,15 @@ async function RecentTradesSection({ hashtagId }: { hashtagId: string }) {
>
{t.type.replace('_', ' ')}
</span>
<a
href={`/profile/${t.user.username}`}
className="text-slate-400 hover:text-slate-200"
>
{t.user.username}
</a>
<div className="flex items-center gap-1">
{t.user.isFund && <span className="text-indigo-400">🏦</span>}
<Link
href={t.user.isFund ? `/fund/${t.user.username.replace('fund:', '')}` : `/profile/${t.user.username}`}
className="text-slate-400 hover:text-slate-200"
>
{t.user.displayUsername ?? t.user.username}
</Link>
</div>
</div>
<div className="text-right">
<span className="text-slate-300">{formatNumber(t.shares)} sh</span>
+182
View File
@@ -0,0 +1,182 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
import Link from 'next/link'
import { TrendingUp } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 50
interface PageProps {
searchParams: { page?: string }
}
export default async function TradeHistoryPage({ searchParams }: PageProps) {
const session = await getServerSession(authOptions)
if (!session) redirect('/auth/signin')
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
const [total, trades] = await Promise.all([
prisma.trade.count({ where: { userId: session.user.id } }),
prisma.trade.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
include: {
hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
},
}),
])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<TrendingUp className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Trade History</h1>
<span className="text-slate-500 text-sm">({total} total)</span>
</div>
{trades.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<TrendingUp className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>No trades yet.</p>
<Link href="/" className="text-indigo-400 hover:text-indigo-300 text-sm mt-2 inline-block">
Browse trending hashtags
</Link>
</div>
) : (
<>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{trades.map((t) => {
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 (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
<span
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
isLiquidation
? 'bg-orange-500/15 text-orange-400'
: isLottery
? 'bg-amber-500/15 text-amber-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'
}`}
>
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span>
<div>
{isLottery ? (
<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
href={`/hashtag/${t.hashtag!.tag}`}
className="hover:text-indigo-300"
>
#{t.hashtag!.displayTag}
</Link>
)}
<p className="text-xs text-slate-500 mt-0.5">
{formatDistanceToNow(t.createdAt, { addSuffix: true })}
</p>
</div>
</div>
<div className="text-right">
{isLottery || t.type === 'ACCOUNT_OPEN' ? (
<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 className="text-xs text-slate-500">{formatCurrency(t.total)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT' || isLiquidation) && (
<p className={`text-xs ${pnlColor(t.profit)}`}>
{formatPnl(t.profit)}
</p>
)}
</>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
{page > 1 && (
<Link
href={`/history?page=${page - 1}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Prev
</Link>
)}
<span className="text-slate-500 text-sm">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link
href={`/history?page=${page + 1}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Next
</Link>
)}
</div>
)}
</>
)}
</div>
)
}
+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'] })
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXTAUTH_URL ?? 'http://localhost:3000'),
title: 'HashEx — The Hashtag Exchange',
description: 'Trade hashtags like stocks. Prices driven by real Mastodon activity.',
}
+252 -96
View File
@@ -2,14 +2,34 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { formatCurrency } from '@/lib/utils'
import { calcFundNav } from '@/lib/pricing'
import Link from 'next/link'
import { Trophy, TrendingUp, TrendingDown } 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 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() {
// Fetch all users with their open positions (to calculate net worth)
const users = await prisma.user.findMany({
where: { isFund: false, isHidden: false },
select: {
id: true,
username: true,
@@ -52,119 +72,255 @@ async function getLeaderboard() {
.slice(0, 50)
}
export default async function LeaderboardPage() {
const [session, players] = await Promise.all([
async function getFundLeaderboard() {
const funds = await prisma.hedgeFund.findMany({
include: {
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 } },
},
orderBy: { createdAt: 'asc' },
})
return funds
.map((f) => {
const portfolioValue = f.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 = f.user.balance + portfolioValue
const nav = calcFundNav(totalValue, f.sharesOutstanding)
return {
id: f.id,
name: f.name,
slug: f.slug,
cash: f.user.balance,
portfolioValue,
totalValue,
nav,
sharesOutstanding: f.sharesOutstanding,
managerCount: f.managers.length,
investorCount: f._count.investments,
}
})
.sort((a, b) => b.totalValue - a.totalValue)
}
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: { tab?: string }
}) {
const tab = searchParams.tab === 'funds' ? 'funds' : 'players'
const [session, players, funds] = await Promise.all([
getServerSession(authOptions),
getLeaderboard(),
getFundLeaderboard(),
])
const myRank = session ? players.findIndex((p) => p.id === session.user.id) + 1 : 0
return (
<div className="max-w-3xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
<div className="flex items-center gap-3">
<Trophy className="h-7 w-7 text-amber-400" />
<div>
<h1 className="text-2xl font-bold">Leaderboard</h1>
<p className="text-sm text-slate-400">Top 50 players by net worth (cash + open positions)</p>
<p className="text-sm text-slate-400">Ranked by net worth (cash + open positions)</p>
</div>
</div>
{session && myRank > 0 && (
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
<span className="font-bold text-indigo-300">
{formatCurrency(players[myRank - 1].netWorth)}
</span>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-surface-card border border-surface-border rounded-xl p-1 w-fit">
<Link
href="/leaderboard?tab=players"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'players' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Users className="h-4 w-4" />
Players
{players.length > 0 && (
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{players.length}</span>
)}
</Link>
<Link
href="/leaderboard?tab=funds"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'funds' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Building2 className="h-4 w-4" />
Hedge Funds
{funds.length > 0 && (
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{funds.length}</span>
)}
</Link>
</div>
{tab === 'players' && (
<>
{session && myRank > 0 && (
<div className="bg-indigo-500/10 border border-indigo-500/30 rounded-xl px-4 py-3 text-sm">
You are ranked <span className="font-bold text-indigo-300">#{myRank}</span> with a net worth of{' '}
<span className="font-bold text-indigo-300">
{formatCurrency(players[myRank - 1].netWorth)}
</span>
</div>
)}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Player</span>
<span className="text-right">Net worth</span>
<span className="text-right hidden sm:block">Cash</span>
<span className="text-right hidden sm:block">Trades</span>
</div>
{players.length === 0 && (
<p className="text-center py-12 text-slate-500">No players yet.</p>
)}
<div className="divide-y divide-surface-border">
{players.map((player, i) => {
const rank = i + 1
const isMe = session?.user.id === player.id
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
key={player.id}
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
} transition-colors`}
>
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
{rankIcon ?? `#${rank}`}
</span>
<Link
href={`/profile/${player.username}`}
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
>
{player.displayUsername ?? player.username}
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
</Link>
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
<span className="text-right text-slate-400 hidden sm:block">
{formatCurrency(player.balance)}
</span>
<span className="text-right text-slate-400 hidden sm:block">
{player.tradeCount}
</span>
</div>
)
})}
</div>
</div>
{/* Unrealized P&L podium */}
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
<section>
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Top unrealized gainers (from open positions)
</h2>
<div className="grid grid-cols-3 gap-3">
{players
.slice()
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
.slice(0, 3)
.map((p) => (
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
{p.displayUsername ?? p.username}
</Link>
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{p.unrealizedPnl >= 0 ? (
<span className="flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" />
+{formatCurrency(p.unrealizedPnl)}
</span>
) : (
<span className="flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" />
{formatCurrency(p.unrealizedPnl)}
</span>
)}
</p>
</div>
))}
</div>
</section>
)}
</>
)}
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Player</span>
<span className="text-right">Net worth</span>
<span className="text-right hidden sm:block">Cash</span>
<span className="text-right hidden sm:block">Trades</span>
</div>
{players.length === 0 && (
<p className="text-center py-12 text-slate-500">No players yet.</p>
)}
<div className="divide-y divide-surface-border">
{players.map((player, i) => {
const rank = i + 1
const isMe = session?.user.id === player.id
const rankIcon =
rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
key={player.id}
className={`grid grid-cols-[2.5rem_1fr_repeat(3,_8rem)] gap-2 items-center px-4 py-3 text-sm ${
isMe ? 'bg-indigo-500/5' : 'hover:bg-surface-hover'
} transition-colors`}
>
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
{rankIcon ?? `#${rank}`}
</span>
<Link
href={`/profile/${player.username}`}
className={`font-medium hover:text-indigo-300 transition-colors ${isMe ? 'text-indigo-300' : ''}`}
>
{player.displayUsername ?? player.username}
{isMe && <span className="ml-2 text-xs text-slate-500">(you)</span>}
</Link>
<span className="text-right font-bold">{formatCurrency(player.netWorth)}</span>
<span className="text-right text-slate-400 hidden sm:block">
{formatCurrency(player.balance)}
</span>
<span className="text-right text-slate-400 hidden sm:block">
{player.tradeCount}
</span>
</div>
)
})}
</div>
</div>
{/* Unrealized P&L podium */}
{players.slice(0, 3).some((p) => p.unrealizedPnl !== 0) && (
<section>
<h2 className="text-sm font-semibold text-slate-400 mb-3 flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Top unrealized gainers (from open positions)
</h2>
<div className="grid grid-cols-3 gap-3">
{players
.slice()
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl)
.slice(0, 3)
.map((p) => (
<div key={p.id} className="bg-surface-card border border-surface-border rounded-xl p-3 text-center">
<Link href={`/profile/${p.username}`} className="font-medium text-sm hover:text-indigo-300">
{p.displayUsername ?? p.username}
</Link>
<p className={`text-sm font-bold mt-1 ${p.unrealizedPnl >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{p.unrealizedPnl >= 0 ? (
<span className="flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" />
+{formatCurrency(p.unrealizedPnl)}
</span>
) : (
<span className="flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" />
{formatCurrency(p.unrealizedPnl)}
</span>
)}
</p>
</div>
))}
{tab === 'funds' && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 px-4 py-2 text-xs text-slate-500 border-b border-surface-border">
<span>#</span>
<span>Fund</span>
<span className="text-right">Total value</span>
<span className="text-right hidden sm:block">NAV/share</span>
<span className="text-right hidden sm:block">Investors</span>
</div>
</section>
{funds.length === 0 && (
<p className="text-center py-12 text-slate-500">No funds yet.</p>
)}
<div className="divide-y divide-surface-border">
{funds.map((fund, i) => {
const rank = i + 1
const rankIcon = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : null
return (
<div
key={fund.id}
className="grid grid-cols-[2.5rem_1fr_repeat(3,_7rem)] gap-2 items-center px-4 py-3 text-sm hover:bg-surface-hover transition-colors"
>
<span className={`font-bold ${rank <= 3 ? 'text-amber-400' : 'text-slate-500'}`}>
{rankIcon ?? `#${rank}`}
</span>
<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>
<span className="text-right font-bold">{formatCurrency(fund.totalValue)}</span>
<span className="text-right text-slate-400 hidden sm:block">
{formatCurrency(fund.nav)}
</span>
<span className="text-right text-slate-400 hidden sm:block">
{fund.investorCount}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
+37 -24
View File
@@ -1,12 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { Loader2, Ticket, CheckCircle2 } from 'lucide-react'
import { Loader2, Dices, CheckCircle2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
const GRID_SIZE = 25
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
type PickResult = {
box: number
@@ -66,7 +65,7 @@ export default function LotteryPage() {
if (!session) {
return (
<div className="max-w-xl mx-auto text-center py-20">
<Ticket className="h-12 w-12 text-indigo-400 mx-auto mb-4" />
<Dices className="h-12 w-12 text-indigo-400 mx-auto mb-4" />
<p className="text-slate-400 mb-4">Sign in to play the Lucky Dip.</p>
<Link
href="/auth/signin"
@@ -86,17 +85,30 @@ export default function LotteryPage() {
)
}
const prizeLabels: Record<number, string> = {
200: '🏆 $200',
50: '🥈 $50',
10: '🎁 $10',
}
const PRIZE_EMOJIS = ['🏆', '🥈', '🎁', '✨', '⭐']
const uniquePrizes = [...new Set(Object.values(PRIZE_MAP))].filter(v => v > 0).sort((a, b) => b - a)
const prizeCounts = uniquePrizes.reduce<Record<number, number>>((acc, amt) => {
acc[amt] = Object.values(PRIZE_MAP).filter(v => v === amt).length
return acc
}, {})
const prizeBoxCount = Object.values(PRIZE_MAP).filter(v => v > 0).length
const emptyBoxCount = GRID_SIZE - prizeBoxCount
const TIER_COLORS = [
{ bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400' },
{ bg: 'bg-slate-500/10', border: 'border-slate-500/20', text: 'text-slate-300' },
{ bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-400' },
{ bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-400' },
{ bg: 'bg-pink-500/10', border: 'border-pink-500/20', text: 'text-pink-400' },
]
const prizeLabels: Record<number, string> = Object.fromEntries(
uniquePrizes.map((amt, i) => [amt, `${PRIZE_EMOJIS[i] ?? '🎁'} ${formatCurrency(amt)}`])
)
return (
<div className="max-w-xl mx-auto space-y-8">
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Ticket className="h-7 w-7 text-indigo-400" />
<Dices className="h-7 w-7 text-indigo-400" />
<h1 className="text-2xl font-bold">Lucky Dip</h1>
</div>
<p className="text-slate-400 text-sm">
@@ -107,21 +119,22 @@ export default function LotteryPage() {
{/* Prize table */}
<div className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p>
<div className="grid grid-cols-3 gap-2 text-sm text-center">
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-2">
<p className="font-bold text-amber-400">$200</p>
<p className="text-xs text-slate-500">×1 box</p>
</div>
<div className="bg-slate-500/10 border border-slate-500/20 rounded-lg p-2">
<p className="font-bold text-slate-300">$50</p>
<p className="text-xs text-slate-500">×2 boxes</p>
</div>
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-2">
<p className="font-bold text-emerald-400">$10</p>
<p className="text-xs text-slate-500">×3 boxes</p>
</div>
<div
className="grid gap-2 text-sm text-center"
style={{ gridTemplateColumns: `repeat(${uniquePrizes.length}, 1fr)` }}
>
{uniquePrizes.map((amt, i) => {
const color = TIER_COLORS[i] ?? TIER_COLORS[TIER_COLORS.length - 1]
const count = prizeCounts[amt]
return (
<div key={amt} className={`${color.bg} border ${color.border} rounded-lg p-2`}>
<p className={`font-bold ${color.text}`}>{formatCurrency(amt)}</p>
<p className="text-xs text-slate-500">×{count} box{count !== 1 ? 'es' : ''}</p>
</div>
)
})}
</div>
<p className="text-xs text-slate-600 mt-2 text-center">Remaining 19 boxes: $0</p>
<p className="text-xs text-slate-600 mt-2 text-center">All remaining {emptyBoxCount} boxes: $0</p>
</div>
{/* Result banner */}
+145 -7
View File
@@ -2,15 +2,21 @@ import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
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 { 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 revalidate = 0
async function getStats() {
const [userCount, hashtagCount, tradeCount, topHashtags, recentTrades] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { isFund: false, isHidden: false } }),
prisma.hashtag.count({ where: { isActive: true } }),
prisma.trade.count(),
// Top by current price (most active)
@@ -27,6 +33,7 @@ async function getStats() {
}),
// Recently traded
prisma.trade.findMany({
where: { hashtagId: { not: null } },
orderBy: { createdAt: 'desc' },
take: 8,
include: { hashtag: true },
@@ -37,13 +44,57 @@ async function getStats() {
return { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }
}
async function getHoldings(userId: string) {
const positions = await prisma.position.findMany({
where: { userId, shares: { gt: 0 } },
include: { hashtag: { select: { tag: true, displayTag: true, currentPrice: true } } },
})
if (positions.length === 0) return null
const withPnl = positions.map((p) => ({
...p,
pnl:
p.positionType === 'LONG'
? (p.hashtag.currentPrice - p.avgBuyPrice) * p.shares
: (p.avgBuyPrice - p.hashtag.currentPrice) * p.shares,
}))
const sorted = [...withPnl].sort((a, b) => b.pnl - a.pnl)
return {
biggestGain: sorted[0].pnl > 0 ? sorted[0] : null,
biggestLoss: sorted[sorted.length - 1].pnl < 0 ? sorted[sorted.length - 1] : null,
}
}
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() {
const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] =
await Promise.all([getServerSession(authOptions), getStats()])
const session = await getServerSession(authOptions)
const [{ userCount, hashtagCount, tradeCount, topHashtags, recentTrades }, holdings, zombieWarnings] =
await Promise.all([
getStats(),
session ? getHoldings(session.user.id) : Promise.resolve(null),
session ? getZombieWarnings(session.user.id) : Promise.resolve([]),
])
return (
<div className="space-y-10">
{/* Hero */}
<AutoRefresh intervalMs={30_000} />
<div className="text-center py-8">
<h1 className="text-4xl font-bold tracking-tight mb-3">
The{' '}
@@ -53,6 +104,9 @@ export default async function HomePage() {
Trade hashtags like stocks. Prices are driven by real-time activity on Mastodon.
Research a tag to unlock it, then buy long or short.
</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">
{session ? (
<>
@@ -95,12 +149,88 @@ export default async function HomePage() {
<StatCard icon={<TrendingUp className="h-5 w-5 text-indigo-400" />} label="Trades executed" value={tradeCount.toLocaleString()} />
</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 && (holdings.biggestGain ?? holdings.biggestLoss) && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-400" />
Your top positions
<Link
href="/positions"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{holdings.biggestGain && (
<Link
href={`/hashtag/${holdings.biggestGain.hashtag.tag}`}
className="bg-surface-card border border-surface-border rounded-xl p-4 hover:border-indigo-500/50 transition-colors"
>
<p className="text-xs text-slate-500 mb-1">Biggest gain</p>
<p className="font-semibold">#{holdings.biggestGain.hashtag.displayTag}</p>
<p className={`text-sm font-medium mt-1 ${pnlColor(holdings.biggestGain.pnl)}`}>
{formatPnl(holdings.biggestGain.pnl)}
</p>
</Link>
)}
{holdings.biggestLoss && (
<Link
href={`/hashtag/${holdings.biggestLoss.hashtag.tag}`}
className="bg-surface-card border border-surface-border rounded-xl p-4 hover:border-indigo-500/50 transition-colors"
>
<p className="text-xs text-slate-500 mb-1">Biggest loss</p>
<p className="font-semibold">#{holdings.biggestLoss.hashtag.displayTag}</p>
<p className={`text-sm font-medium mt-1 ${pnlColor(holdings.biggestLoss.pnl)}`}>
{formatPnl(holdings.biggestLoss.pnl)}
</p>
</Link>
)}
</div>
</section>
)}
{/* Top hashtags */}
{topHashtags.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-400" />
Trending now
<Link
href="/stocks"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
Full market
</Link>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{topHashtags.map((h) => {
@@ -122,9 +252,17 @@ export default async function HomePage() {
{/* Recently traded */}
{recentTrades.length > 0 && (
<section>
<h2 className="text-lg font-semibold mb-4">Recently traded</h2>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
Recently traded
<Link
href="/trades"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
Full feed
</Link>
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{recentTrades.map(({ hashtag }) => (
{recentTrades.map(({ hashtag }) => hashtag && (
<HashtagCard
key={hashtag.id}
tag={hashtag.tag}
+290
View File
@@ -0,0 +1,290 @@
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
import { calcFundNav } from '@/lib/pricing'
import Link from 'next/link'
import { Coins, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
export const dynamic = 'force-dynamic'
function Sparkline({ prices }: { prices: number[] }) {
if (prices.length < 2) return <span className="text-slate-600 text-xs"></span>
const min = Math.min(...prices)
const max = Math.max(...prices)
const range = max - min || 1
const w = 80
const h = 28
const pts = prices
.map((p, i) => {
const x = (i / (prices.length - 1)) * w
const y = h - ((p - min) / range) * (h - 4) - 2
return `${x},${y}`
})
.join(' ')
const up = prices[prices.length - 1] >= prices[0]
return (
<svg width={w} height={h} style={{ overflow: 'visible' }}>
<polyline
points={pts}
fill="none"
stroke={up ? '#34d399' : '#f87171'}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
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)
if (!session) redirect('/auth/signin')
const rawPositions = await prisma.position.findMany({
where: { userId: session.user.id, shares: { gt: 0 } },
include: {
hashtag: {
select: {
tag: true,
displayTag: true,
currentPrice: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 20,
select: { price: true },
},
},
},
},
})
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 (
<div className="max-w-4xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
<div className="flex items-center gap-3">
<Coins className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Open Positions</h1>
</div>
{positions.length === 0 && fundHoldings.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<Coins className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>You have no open positions.</p>
<Link href="/" className="text-indigo-400 hover:text-indigo-300 text-sm mt-2 inline-block">
Browse trending hashtags
</Link>
</div>
) : (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Header row */}
<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">
<SortHeader col="hashtag" label="Hashtag" currentSort={sortKey} currentDir={sortDir} />
<SortHeader col="shares" label="Shares" currentSort={sortKey} currentDir={sortDir} right />
<SortHeader col="avgBuy" label="Avg buy" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="current" label="Current" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="costBasis" label="Cost basis" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="value" label="Value" currentSort={sortKey} currentDir={sortDir} right className="hidden sm:flex" />
<SortHeader col="pnl" label="P&L" currentSort={sortKey} currentDir={sortDir} right />
</div>
<div className="divide-y divide-surface-border">
{positions.map((pos) => (
<div
key={pos.id}
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"
>
{/* Hashtag + type badge (+ sparkline on desktop) */}
<div className="flex items-center gap-3 min-w-0">
<div className="hidden sm:block shrink-0">
<Sparkline prices={pos.sparkPrices} />
</div>
<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>
<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>
)
}
@@ -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>
)
}
+239 -36
View File
@@ -3,10 +3,17 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { getBalanceTier } from '@/lib/pricing'
import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
import { TrendingUp, TrendingDown, Coins, Building2, AlertTriangle } from 'lucide-react'
import ChangePasswordForm from './ChangePasswordForm'
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'
@@ -14,6 +21,26 @@ interface Props {
params: { username: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const username = decodeURIComponent(params.username).toLowerCase()
const user = await prisma.user.findUnique({
where: { username },
select: { displayUsername: true, balance: true, _count: { select: { trades: true } } },
})
const displayName = user?.displayUsername ?? username
const title = `${displayName} — HashEx Profile`
const description = user
? `Check out ${displayName}'s trading profile on HashEx.`
: `HashEx trader profile for @${username}.`
const imageUrl = `/api/og/profile/${encodeURIComponent(username)}`
return {
title,
description,
openGraph: { title, description, images: [{ url: imageUrl, width: 1200, height: 630, alt: `${displayName}'s profile` }] },
twitter: { card: 'summary_large_image', title, description, images: [imageUrl] },
}
}
export default async function ProfilePage({ params }: Props) {
const session = await getServerSession(authOptions)
const username = decodeURIComponent(params.username).toLowerCase()
@@ -35,7 +62,16 @@ export default async function ProfilePage({ params }: Props) {
trades: {
orderBy: { createdAt: 'desc' },
take: 30,
include: { hashtag: { select: { tag: true, displayTag: true } } },
include: {
hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
},
},
managedFunds: {
orderBy: { addedAt: 'asc' },
select: {
fund: { select: { id: true, name: true, slug: true } },
},
},
},
})
@@ -64,24 +100,69 @@ export default async function ProfilePage({ params }: Props) {
.filter((t) => t.type === 'SELL_LONG' || t.type === 'SELL_SHORT')
.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 (
<div className="max-w-4xl mx-auto space-y-8">
{/* 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>
<h1 className="text-3xl font-bold">{user.displayUsername ?? user.username}</h1>
{user.displayUsername && user.displayUsername.toLowerCase() !== user.username && (
<p className="text-slate-500 text-sm">@{user.username}</p>
)}
{isOwn && (
<p className="text-slate-400 text-sm mt-1">
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
</p>
<div className="mt-1 text-sm text-slate-400 space-y-0.5">
{(() => {
const tier = getBalanceTier(user.balance)
return (
<>
<p>
<span className="text-slate-300 font-medium">Tier {tier.level}</span>
<span className="text-slate-600 mx-1.5">&middot;</span>
{tier.pointsPerDay} research pt{tier.pointsPerDay !== 1 ? 's' : ''}/day
{tier.nextThreshold && (
<>
<span className="hidden sm:inline text-slate-600 text-xs ml-1.5">
(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>
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
</p>
</>
)
})()}
</div>
)}
</div>
<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>
{lotteryCount > 0 && (
<p className="text-xs text-amber-400 mt-1">
🎰 {formatCurrency(lotteryWinnings)} from Lucky Dip ({lotteryCount} win{lotteryCount !== 1 ? 's' : ''})
</p>
)}
</div>
</div>
@@ -101,15 +182,62 @@ export default async function ProfilePage({ params }: Props) {
/>
</div>
{/* Account settings — only shown to the profile owner */}
{isOwn && (
<>
<AccountSettingsForm
currentUsername={user.username}
currentDisplayUsername={user.displayUsername ?? null}
{/* 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}
/>
<ChangePasswordForm />
</>
</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 */}
@@ -118,6 +246,14 @@ export default async function ProfilePage({ params }: Props) {
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Coins className="h-5 w-5 text-indigo-400" />
Open positions
{isOwn && (
<Link
href="/positions"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
)}
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
@@ -167,42 +303,109 @@ export default async function ProfilePage({ params }: Props) {
<TrendingDown className="h-5 w-5 text-indigo-400" />
)}
Trade history
{isOwn && (
<Link
href="/history"
className="ml-auto text-sm font-normal text-indigo-400 hover:text-indigo-300"
>
View all
</Link>
)}
</h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{user.trades.map((t) => (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3">
{user.trades.map((t) => {
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 (
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
{/* Primary row: badge · hashtag/label · total */}
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded ${
t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
isLiquidation
? 'bg-orange-500/15 text-orange-400'
: isLottery
? 'bg-amber-500/15 text-amber-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('_', ' ')}
{isLiquidation ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span>
{isLottery ? (
<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
href={`/hashtag/${t.hashtag!.tag}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
>
#{t.hashtag!.displayTag}
</Link>
)}
<span className="shrink-0 font-medium tabular-nums">
{formatCurrency(isLottery ? t.profit : t.total)}
</span>
<Link
href={`/hashtag/${t.hashtag.tag}`}
className="hover:text-indigo-300"
>
#{t.hashtag.displayTag}
</Link>
</div>
<div className="text-right">
<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>
{/* Secondary row: time (left) · shares @ price (right) */}
<div className="flex items-center justify-between text-xs text-slate-500">
<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>
)}
</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>
</div>
</section>
)}
{/* Account settings — only shown to the profile owner */}
{isOwn && (
<>
<AccountSettingsForm
currentUsername={user.username}
currentDisplayUsername={user.displayUsername ?? null}
/>
<ChangePasswordForm />
<ResetAccountForm username={user.username} />
<CloseAccountForm username={user.username} />
</>
)}
</div>
)
}
+387
View File
@@ -0,0 +1,387 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency, pnlColor } from '@/lib/utils'
import Link from 'next/link'
import { ArrowUp, ArrowDown, ArrowUpDown, BarChart2, Building2 } from 'lucide-react'
import { AutoRefresh } from '@/components/AutoRefresh'
import { formatDistanceToNow } from 'date-fns'
import { calcFundNav } from '@/lib/pricing'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
type SortField = 'price' | 'tag' | 'change' | 'updated'
type SortDir = 'asc' | 'desc'
interface PageProps {
searchParams: { page?: string; sort?: string; dir?: string; tab?: string; fund?: string }
}
function SortLink({
field,
label,
currentSort,
currentDir,
page,
fund,
}: {
field: SortField
label: string
currentSort: SortField
currentDir: SortDir
page: number
fund?: string
}) {
const isActive = currentSort === field
const nextDir: SortDir = isActive && currentDir === 'desc' ? 'asc' : 'desc'
const Icon = isActive ? (currentDir === 'desc' ? ArrowDown : ArrowUp) : ArrowUpDown
const fundParam = fund ? `&fund=${encodeURIComponent(fund)}` : ''
return (
<Link
href={`/stocks?page=1&sort=${field}&dir=${nextDir}&tab=stocks${fundParam}`}
className={`flex items-center gap-1 hover:text-slate-200 transition-colors select-none ${isActive ? 'text-indigo-400' : 'text-slate-400'}`}
>
{label}
<Icon className="h-3 w-3" />
</Link>
)
}
export default async function StocksPage({ searchParams }: PageProps) {
const tab = searchParams.tab === 'funds' ? 'funds' : 'stocks'
const fund = searchParams.fund ? decodeURIComponent(searchParams.fund) : undefined
const fundParam = fund ? `&fund=${encodeURIComponent(fund)}` : ''
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
const sort = (['price', 'tag', 'change', 'updated'].includes(searchParams.sort ?? '')
? searchParams.sort
: 'price') as SortField
const dir = (searchParams.dir === 'asc' ? 'asc' : 'desc') as SortDir
// For change sort: fetch all active hashtags with last 2 price points and sort in-memory
// For other sorts: use DB-native orderBy + pagination
let stocks: {
id: string
tag: string
displayTag: string
currentPrice: number
lastUpdated: Date
previousPrice: number | null
postsPerHour: number | null
holderCount: number
}[]
let total: number
if (sort === 'change') {
// Fetch all active hashtags for in-memory sort
const all = await prisma.hashtag.findMany({
where: { isActive: true },
select: {
id: true,
tag: true,
displayTag: true,
currentPrice: true,
lastUpdated: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
},
})
const computed = all.map((h) => ({
id: h.id,
tag: h.tag,
displayTag: h.displayTag,
currentPrice: h.currentPrice,
lastUpdated: h.lastUpdated,
previousPrice: h.priceHistory[1]?.price ?? null,
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
holderCount: h._count.positions,
changePct:
h.priceHistory[1]?.price != null
? ((h.currentPrice - h.priceHistory[1].price) / h.priceHistory[1].price) * 100
: 0,
}))
computed.sort((a, b) =>
dir === 'desc' ? b.changePct - a.changePct : a.changePct - b.changePct,
)
total = computed.length
stocks = computed.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
} else {
const orderBy =
sort === 'price'
? { currentPrice: dir }
: sort === 'tag'
? { tag: dir }
: { lastUpdated: dir }
;[total, stocks] = await Promise.all([
prisma.hashtag.count({ where: { isActive: true } }),
prisma.hashtag
.findMany({
where: { isActive: true },
orderBy,
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
select: {
id: true,
tag: true,
displayTag: true,
currentPrice: true,
lastUpdated: true,
priceHistory: {
orderBy: { recordedAt: 'desc' },
take: 2,
select: { price: true, postsPerHour: true },
},
_count: { select: { positions: { where: { shares: { gt: 0 } } } } },
},
})
.then((rows) =>
rows.map((h) => ({
id: h.id,
tag: h.tag,
displayTag: h.displayTag,
currentPrice: h.currentPrice,
lastUpdated: h.lastUpdated,
previousPrice: h.priceHistory[1]?.price ?? null,
postsPerHour: h.priceHistory[0]?.postsPerHour ?? null,
holderCount: h._count.positions,
})),
),
])
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
// Funds (always fetched for both tabs — cheap query)
const rawFunds = await prisma.hedgeFund.findMany({
include: {
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 } },
},
orderBy: { createdAt: 'asc' },
})
const funds = rawFunds.map((f) => {
const portfolioValue = f.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 = f.user.balance + portfolioValue
return {
id: f.id, name: f.name, slug: f.slug,
cash: f.user.balance, portfolioValue, totalValue,
nav: calcFundNav(totalValue, f.sharesOutstanding),
managerCount: f.managers.length,
investorCount: f._count.investments,
}
}).sort((a, b) => b.totalValue - a.totalValue)
return (
<div className="max-w-5xl mx-auto space-y-6">
<AutoRefresh intervalMs={30_000} />
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BarChart2 className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Markets</h1>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-surface-card border border-surface-border rounded-xl p-1 w-fit">
<Link
href="/stocks?tab=stocks"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'stocks' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<BarChart2 className="h-4 w-4" />
Hashtags
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{total}</span>
</Link>
<Link
href="/stocks?tab=funds"
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
tab === 'funds' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-slate-200'
}`}
>
<Building2 className="h-4 w-4" />
Hedge Funds
<span className="text-xs bg-white/10 px-1.5 py-0.5 rounded-full">{funds.length}</span>
</Link>
</div>
{/* Fund mode banner */}
{fund && (
<div className="flex items-center justify-between gap-3 bg-indigo-500/10 border border-indigo-500/20 rounded-xl px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-indigo-400 shrink-0" />
<span className="text-indigo-200">Trading as <span className="font-semibold">{fund}</span> click any hashtag to trade on behalf of this fund</span>
</div>
<Link href="/stocks" className="text-xs text-slate-400 hover:text-slate-200 shrink-0">Exit fund mode ×</Link>
</div>
)}
{tab === 'stocks' && (<>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
{/* Column headers */}
<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} />
<div className="flex justify-end">
<SortLink field="price" label="Price" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div>
<div className="flex justify-end">
<SortLink field="change" label="Change" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div>
<div className="text-right hidden sm:block text-slate-400">Posts/hr</div>
<div className="hidden sm:flex justify-end">
<SortLink field="updated" label="Updated" currentSort={sort} currentDir={dir} page={page} fund={fund} />
</div>
</div>
{stocks.length === 0 ? (
<div className="text-center py-16 text-slate-500">No active hashtags yet.</div>
) : (
<div className="divide-y divide-surface-border">
{stocks.map((stock, i) => {
const prev = stock.previousPrice
const change = prev != null ? stock.currentPrice - prev : null
const changePct = prev != null && prev > 0 ? ((stock.currentPrice - prev) / prev) * 100 : null
return (
<div
key={stock.id}
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 */}
<div className="flex items-center gap-3 min-w-0">
<span className="text-slate-600 text-xs w-6 shrink-0 text-right">
{(page - 1) * PAGE_SIZE + i + 1}
</span>
<Link
href={`/hashtag/${stock.tag}${fund ? `?fund=${encodeURIComponent(fund)}` : ''}`}
className="font-medium hover:text-indigo-300 transition-colors truncate"
>
#{stock.displayTag}
</Link>
</div>
{/* Price */}
<div className="text-right font-medium tabular-nums">
{formatCurrency(stock.currentPrice)}
</div>
{/* Change */}
<div className="text-right tabular-nums">
{change == null ? (
<span className="text-slate-600 text-xs"></span>
) : (
<div>
<p className={`text-sm ${pnlColor(change)}`}>
{change > 0 ? '+' : ''}{formatCurrency(change)}
</p>
<p className={`text-xs ${pnlColor(changePct ?? 0)}`}>
{changePct! > 0 ? '+' : ''}{changePct!.toFixed(2)}%
</p>
</div>
)}
</div>
{/* Posts/hr */}
<div className="text-right hidden sm:block text-slate-400 text-sm tabular-nums">
{stock.postsPerHour != null ? stock.postsPerHour.toFixed(1) : '—'}
</div>
{/* Last updated */}
<div className="text-right text-xs text-slate-500 hidden sm:block">
{formatDistanceToNow(stock.lastUpdated, { addSuffix: true })}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
{page > 1 && (
<Link
href={`/stocks?page=${page - 1}&sort=${sort}&dir=${dir}&tab=stocks${fundParam}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Prev
</Link>
)}
<span className="text-slate-500 text-sm">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link
href={`/stocks?page=${page + 1}&sort=${sort}&dir=${dir}&tab=stocks${fundParam}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Next
</Link>
)}
</div>
)}
</>)}
{tab === 'funds' && (
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_repeat(3,_8rem)] gap-4 px-4 py-2.5 border-b border-surface-border text-xs text-slate-500">
<span>Fund</span>
<span className="text-right">Total value</span>
<span className="text-right hidden sm:block">NAV / share</span>
<span className="text-right hidden sm:block">Investors</span>
</div>
{funds.length === 0 ? (
<p className="text-center py-16 text-slate-500">No hedge funds yet.</p>
) : (
<div className="divide-y divide-surface-border">
{funds.map((fund) => (
<div
key={fund.id}
className="grid grid-cols-[1fr_repeat(3,_8rem)] gap-4 items-center px-4 py-3 hover:bg-surface-border/30 transition-colors"
>
<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}
<span className="text-xs text-slate-500">{fund.managerCount} manager{fund.managerCount !== 1 ? 's' : ''}</span>
</Link>
<span className="text-right font-medium tabular-nums">{formatCurrency(fund.totalValue)}</span>
<span className="text-right text-slate-400 text-sm hidden sm:block tabular-nums">{formatCurrency(fund.nav)}</span>
<span className="text-right text-slate-400 text-sm hidden sm:block">{fund.investorCount}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { prisma } from '@/lib/prisma'
import { formatCurrency, formatNumber, formatPnl, pnlColor } from '@/lib/utils'
import Link from 'next/link'
import { Activity } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 50
interface PageProps {
searchParams: { page?: string }
}
export default async function GlobalTradesPage({ searchParams }: PageProps) {
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10))
const tradeWhere = {
OR: [
{ hashtagId: { not: null as string | null }, type: { not: 'LOTTERY_WIN' as const } },
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as ('FUND_INVEST' | 'FUND_REDEEM')[] } },
],
}
const [total, trades] = await Promise.all([
prisma.trade.count({ where: tradeWhere }),
prisma.trade.findMany({
where: {
OR: [
{ hashtagId: { not: null }, type: { not: 'LOTTERY_WIN' as const } },
{ type: { in: ['FUND_INVEST', 'FUND_REDEEM'] as const } },
],
},
orderBy: { createdAt: 'desc' },
take: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
include: {
hashtag: { select: { tag: true, displayTag: true } },
fund: { select: { name: true, slug: true } },
user: { select: { username: true, displayUsername: true, isFund: true } },
},
}),
])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Activity className="h-6 w-6 text-indigo-400" />
<h1 className="text-2xl font-bold">Trade Feed</h1>
<span className="text-slate-500 text-sm">({total.toLocaleString()} trades)</span>
</div>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border">
{trades.map((t) => (
<div key={t.id} className="px-4 py-3 text-sm space-y-1.5">
{/* Primary row: type badge · hashtag · total value */}
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded shrink-0 ${
(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT')
? 'bg-orange-500/15 text-orange-400'
: (t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM')
? 'bg-indigo-500/15 text-indigo-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}
>
{(t.type === 'LIQUIDATE_LONG' || t.type === 'LIQUIDATE_SHORT') ? 'LIQUIDATED' : t.type.replace(/_/g, ' ')}
</span>
{(t.type === 'FUND_INVEST' || t.type === 'FUND_REDEEM') ? (
t.fund ? (
<Link
href={`/fund/${t.fund.slug}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
>
{t.fund.name}
</Link>
) : (
<span className="text-slate-500 font-medium flex-1 min-w-0">Deleted Fund</span>
)
) : (
<Link
href={`/hashtag/${t.hashtag!.tag}`}
className="text-indigo-300 hover:text-indigo-200 font-medium truncate flex-1 min-w-0"
>
#{t.hashtag!.displayTag}
</Link>
)}
<span className="shrink-0 font-medium tabular-nums">{formatCurrency(t.total)}</span>
</div>
{/* Secondary row: user · time (left) shares @ price (right) */}
<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>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
{page > 1 && (
<Link
href={`/trades?page=${page - 1}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Prev
</Link>
)}
<span className="text-slate-500 text-sm">Page {page} of {totalPages}</span>
{page < totalPages && (
<Link
href={`/trades?page=${page + 1}`}
className="px-3 py-1.5 text-sm bg-surface-card border border-surface-border rounded-lg hover:border-indigo-500/50 transition-colors"
>
Next
</Link>
)}
</div>
)}
</div>
)
}
+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 { TrendingUp, TrendingDown } from 'lucide-react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
interface Props {
@@ -16,7 +16,7 @@ export function HashtagCard({ tag, displayTag, currentPrice, previousPrice, post
? ((currentPrice - previousPrice) / previousPrice) * 100
: null
const up = pctChange === null ? null : pctChange >= 0
const up = pctChange === null ? null : pctChange > 0 ? 'up' : pctChange < 0 ? 'down' : 'flat'
return (
<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"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-sm">#{displayTag}</p>
<div className="min-w-0">
<p className="font-semibold text-sm truncate">#{displayTag}</p>
{postsPerHour !== undefined && (
<p className="text-xs text-slate-500 mt-0.5">
{postsPerHour.toFixed(1)} posts/hr
</p>
)}
</div>
<div className="text-right">
<div className="text-right shrink-0">
<p className="font-bold text-sm">{formatCurrency(currentPrice)}</p>
{pctChange !== null && (
<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" />
) : (
) : up === 'down' ? (
<TrendingDown className="h-3 w-3" />
) : (
<Minus className="h-3 w-3" />
)}
{up ? '+' : ''}
{up === 'up' ? '+' : ''}
{pctChange.toFixed(1)}%
</div>
)}
+115 -29
View File
@@ -3,25 +3,94 @@
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useState, useRef, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { formatCurrency } from '@/lib/utils'
import { normalizeTag } from '@/lib/utils'
export function Navbar() {
const { data: session } = useSession()
type Suggestion = { tag: string; displayTag: string; currentPrice: number }
function NavSearchInner() {
const router = useRouter()
const searchParams = useSearchParams()
const fundSlug = searchParams.get('fund')
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function navigate(tag: string) {
const url = fundSlug ? `/hashtag/${tag}?fund=${encodeURIComponent(fundSlug)}` : `/hashtag/${tag}`
router.push(url)
setQuery('')
setSuggestions([])
setShowSuggestions(false)
}
function handleSearch(e: React.FormEvent) {
e.preventDefault()
const tag = normalizeTag(query)
if (tag) {
router.push(`/hashtag/${tag}`)
setQuery('')
}
if (tag) navigate(tag)
}
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
const normalized = val.replace(/^#/, '').trim().toLowerCase()
if (normalized.length < 1) {
setSuggestions([])
setShowSuggestions(false)
return
}
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/hashtags/search?q=${encodeURIComponent(normalized)}`)
if (res.ok) {
const data: Suggestion[] = await res.json()
setSuggestions(data)
setShowSuggestions(data.length > 0)
}
} catch {}
}, 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 (
<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">
@@ -32,19 +101,17 @@ export function Navbar() {
<span className="font-bold text-lg hidden sm:block">HashEx</span>
</Link>
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
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"
/>
{/* Search — NavSearchInner uses useSearchParams() to preserve ?fund= context */}
<Suspense fallback={
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<input disabled placeholder="#hashtag" className="w-full bg-surface border border-surface-border rounded-lg pl-9 pr-3 py-1.5 text-sm" />
</div>
</div>
</form>
}>
<NavSearchInner />
</Suspense>
{/* Right section */}
<div className="flex items-center gap-3 shrink-0">
@@ -104,21 +171,40 @@ export function Navbar() {
// Lazy balance fetcher so the navbar always shows current value
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)
// One-shot fetch on mount
if (typeof window !== 'undefined' && balance === null) {
fetch('/api/user/me')
.then((r) => r.json())
.then((d) => setBalance(d.balance ?? null))
.catch(() => {})
}
useEffect(() => {
let cancelled = false
function fetchBalance() {
fetch('/api/user/me')
.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
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)}
</span>
)
@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function RetryFailedButton({
queueName,
count,
}: {
queueName: string
count: number
}) {
const [loading, setLoading] = useState(false)
const router = useRouter()
if (count === 0) return null
async function handleRetry() {
setLoading(true)
try {
await fetch(`/api/admin/queues/${encodeURIComponent(queueName)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'retry-failed' }),
})
} finally {
router.refresh()
setLoading(false)
}
}
return (
<button
onClick={handleRetry}
disabled={loading}
className="text-xs px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 transition-colors"
>
{loading ? 'Retrying…' : `Retry ${count} failed`}
</button>
)
}
+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>
)
}
+127 -20
View File
@@ -17,11 +17,25 @@ function extractMaxId(linkHeader: string | null): string | null {
return match ? match[1] : null
}
async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
/**
* Extracts hashtag names from Mastodon HTML content with original casing preserved.
* Mastodon renders hashtags as: #<span>TagName</span> inside an anchor.
*/
function extractTagsFromHtml(html: string): string[] {
const results: string[] = []
const re = /#<span>([^<]+)<\/span>/gi
let m: RegExpExecArray | null
while ((m = re.exec(html)) !== null) {
results.push(m[1]) // preserve original casing
}
return results
}
async function fetchPage(tag: string, maxId?: string, postLimit = 20): Promise<TimelineResult> {
const instance = process.env.MASTODON_INSTANCE
if (!instance) throw new Error('MASTODON_INSTANCE is not configured')
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=40`
let url = `${instance}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${postLimit}`
if (maxId) url += `&max_id=${maxId}`
const headers: HeadersInit = { Accept: 'application/json' }
@@ -46,8 +60,6 @@ async function fetchPage(tag: string, maxId?: string): Promise<TimelineResult> {
/**
* Fetches recent posts for a hashtag and returns posts-per-hour.
* Paginates when all fetched posts share a very tight timestamp window
* (e.g., #happynewyear at midnight) up to MAX_PAGES_PER_HASHTAG pages.
*/
export async function getPostsPerHour(tag: string): Promise<number> {
const { postsPerHour } = await getPostsData(tag)
@@ -57,43 +69,90 @@ export async function getPostsPerHour(tag: string): Promise<number> {
/**
* Returns posts-per-hour AND a sorted list of co-occurring tag names
* (lowercased, excluding the queried tag itself).
*
* Pagination strategy:
* - Keep fetching pages until >= 50% of posts in a page fall outside the 1-hour window,
* OR the timeline is exhausted, OR MAX_PAGES_PER_HASHTAG is reached.
* - The 50% rule handles federated out-of-order posts gracefully: Mastodon timelines are
* ordered by post ID (local receive time), not created_at. A remote post from hours or
* even years ago can arrive late, get a fresh ID, and appear at the top of the stream.
* A minority of such posts won't trigger the stop condition; only once the majority of
* a page is old content do we consider the 1-hour window fully covered.
* - After collecting all pages, sort by created_at and filter to the last hour for an
* accurate count regardless of any remaining ordering noise.
*
* PPH calculation:
* - Crossed horizon (direct): we have a full window — count posts with created_at >= cutoff.
* - Hit page cap without crossing (burst): more posts exist beyond what we fetched —
* extrapolate from the covered time span (count / coveredHours).
* - Timeline exhausted without crossing (sparse): all posts in the last hour are accounted
* for — use the raw count directly (no extrapolation).
*/
export async function getPostsData(
tag: string,
): Promise<{ postsPerHour: number; relatedTags: string[] }> {
): Promise<{ postsPerHour: number; relatedTags: string[]; displayTag?: string; hasAnyPosts: boolean }> {
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 now = Date.now()
const cutoff = now - ONE_HOUR_MS
let allPosts: MastodonPost[] = []
let maxId: string | undefined
let hitPageCap = false
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
allPosts = [...allPosts, ...posts]
// Stop paginating if we got fewer than 40 posts (end of timeline)
if (posts.length < 40 || !nextMaxId) break
// End of timeline or no more pages
if (posts.length < postLimit || !nextMaxId) break
// Stop paginating if the time span of what we have is already > 5 minutes
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const spanMs = Math.max(...times) - Math.min(...times)
if (spanMs > 5 * 60 * 1000) break
// Stop when >= 50% of this page's posts are outside the 1-hour window.
// A handful of old federated posts won't trigger this; once the majority of a page
// is old content we have a reliable picture of the last hour.
const outsideWindow = posts.filter((p) => new Date(p.created_at).getTime() < cutoff).length
if (outsideWindow / posts.length >= 0.5) break
maxId = nextMaxId
if (page === maxPages - 1) hitPageCap = true
}
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [] }
if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [], hasAnyPosts: false }
// Sort globally by created_at so the window filter is accurate regardless of federation order
allPosts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
const times = allPosts.map((p) => new Date(p.created_at).getTime())
const newestMs = Math.max(...times)
const oldestMs = Math.min(...times)
const newestMs = times[0]
const oldestMs = times[times.length - 1]
// Minimum 1-minute span to handle flood scenario (all same timestamp)
const spanHours = Math.max((newestMs - oldestMs) / (1000 * 60 * 60), 1 / 60)
const postsPerHour = allPosts.length / spanHours
let postsPerHour: number
if (oldestMs < cutoff) {
// We reached (or passed) the 1-hour horizon — count posts within the last hour directly
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
} else if (hitPageCap) {
// Hit the page cap and never reached the horizon — burst scenario, more posts exist
// beyond what we fetched. Extrapolate using only in-window posts:
// rate = inWindowCount / coveredHours, where coveredHours = (now - oldestInWindowPost) / ONE_HOUR_MS
// This gives posts-per-hour as if the same rate continued for the full 60 minutes.
// Minimum 1-minute covered span to avoid divide-by-zero on a single-post window.
const inWindowPosts = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff)
const oldestInWindowMs = inWindowPosts.length > 0
? Math.min(...inWindowPosts.map((p) => new Date(p.created_at).getTime()))
: newestMs
const coveredMs = Math.max(now - oldestInWindowMs, 60_000)
postsPerHour = inWindowPosts.length / (coveredMs / ONE_HOUR_MS)
} else {
// Timeline exhausted — these are all the posts that exist within the last hour.
// Use the raw count directly; extrapolating would inflate a sparse tag.
postsPerHour = allPosts.filter((p) => new Date(p.created_at).getTime() >= cutoff).length
}
// Count co-occurring tags
// Count co-occurring tags from the API tags object (authoritative for membership)
const counts = new Map<string, number>()
const lowerTag = tag.toLowerCase()
for (const post of allPosts) {
@@ -110,5 +169,53 @@ export async function getPostsData(
.slice(0, 10)
.map(([name]) => name)
return { postsPerHour, relatedTags }
// Derive the most common casing variant for the queried tag itself.
// Merges post.tags (e.g. "JavaScript") with HTML-rendered span variants for better coverage.
const casingCounts = new Map<string, number>()
for (const post of allPosts) {
// From API tags array - this is the source of truth for which tags are actually present on the post, but it doesn't preserve original casing (all lowercase).
// for (const t of post.tags ?? []) {
// if (t.name.toLowerCase() === lowerTag) {
// casingCounts.set(t.name, (casingCounts.get(t.name) ?? 0) + 1)
// }
// }
// From HTML content (preserves original casing as typed by the user)
for (const variant of extractTagsFromHtml(post.content)) {
if (variant.toLowerCase() === lowerTag) {
casingCounts.set(variant, (casingCounts.get(variant) ?? 0) + 1)
}
}
}
let displayTag: string | undefined
if (casingCounts.size > 0) {
const total = [...casingCounts.values()].reduce((a, b) => a + b, 0)
const [topVariant, topCount] = [...casingCounts.entries()].sort((a, b) => b[1] - a[1])[0]
if (topCount / total >= 0.5) displayTag = topVariant
}
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 }
}
+49 -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.
* Examples:
* 1 post/hr → $0.25
* 10 posts/hr → $2.50
* 100 → $25.00
* 1000 → $250.00
* 12 000 (viral #happynewyear) → $3 000
* Formula: price = base * pph / (1 + k * pph)
* where k is chosen so the curve hits $250 at 3 600 PPH.
*
* Anchor points:
* 1 post/hr → ~$0.25
* 10 posts/hr~$2.48
* 100 posts/hr → ~$23.32
* 1 000 → ~$145
* 3 600 (viral) → $250.00 (design target)
* Asymptote → ~$346
*
* Floor: $0.25 for ≤ 1 PPH.
*/
export function calcPrice(postsPerHour: number): number {
if (postsPerHour <= 0) return 0.25
return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100)
if (postsPerHour <= 1) return 0.25
const base = 0.25 // The base price at low volumes (1 PPH)
const anchor = 3600 // PPH at which we want the target price (1 PPS)
const target = 250 // price at the anchor PPH
const k = ((base * anchor / target) - 1) / anchor
const price = base * postsPerHour / (1 + k * postsPerHour)
return Math.max(0.25, Math.round(price * 100) / 100)
}
/**
@@ -25,6 +35,29 @@ export function dailyResearchPoints(balance: number): number {
return 1
}
export interface BalanceTier {
level: number
pointsPerDay: number
nextThreshold: number | null
}
/** Returns the tier info for a given balance. */
export function getBalanceTier(balance: number): BalanceTier {
if (balance >= 1_000_000) return { level: 4, pointsPerDay: 5, nextThreshold: null }
if (balance >= 100_000) return { level: 3, pointsPerDay: 3, nextThreshold: 1_000_000 }
if (balance >= 10_000) return { level: 2, pointsPerDay: 2, nextThreshold: 100_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. */
export function calcFundNav(totalValue: number, sharesOutstanding: number): number {
if (sharesOutstanding <= 0) return 1.00
return totalValue / sharesOutstanding
}
/**
* Calculate the cost/proceeds and realized P&L for a trade.
*
@@ -58,8 +91,12 @@ export function calcTrade(
return { total, balanceDelta: -total, profit: 0 }
}
case 'SELL_SHORT': {
const returned = Math.max(0, (2 * avgBuyPrice - price) * shares)
const profit = returned - avgBuyPrice * shares
// The collateral model: BUY_SHORT debited avgBuyPrice*shares. On close the
// 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 }
}
}
+8
View File
@@ -42,3 +42,11 @@ export const schedulerQueue = new Queue('hashex-scheduler', {
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 {
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', {
style: 'currency',
currency: 'USD',
@@ -32,7 +41,11 @@ export function pnlColor(value: number): string {
return 'text-slate-400'
}
/** Normalize a hashtag: lowercase, strip leading #, trim whitespace */
/** Normalize a hashtag: lowercase, strip leading #, remove all whitespace and
* any character that isn't a letter, digit, or underscore. */
export function normalizeTag(raw: string): string {
return raw.trim().replace(/^#+/, '').toLowerCase()
return raw
.replace(/^#+/, '') // strip leading #
.replace(/[\s]/g, '') // remove all whitespace
.toLowerCase()
}
+3 -1
View File
@@ -2,12 +2,14 @@ export { default } from 'next-auth/middleware'
export const config = {
matcher: [
'/profile/:path*',
'/positions',
'/history',
'/admin/:path*',
'/api/trade/:path*',
'/api/research/:path*',
'/api/user/:path*',
'/api/admin/:path*',
'/api/lottery/:path*',
'/api/funds/:path*',
],
}
+326 -42
View File
@@ -13,7 +13,7 @@
import { Worker, Queue } from 'bullmq'
import { PrismaClient } from '@prisma/client'
import { getPostsData } from '../lib/mastodon'
import { calcPrice, dailyResearchPoints } from '../lib/pricing'
import { calcPrice, calcTrade, dailyResearchPoints, calcFundNav, round2 } from '../lib/pricing'
// ── Connection options ────────────────────────────────────────────────────────
// Use plain connection options so BullMQ uses its own bundled ioredis,
@@ -33,20 +33,69 @@ function redisOpts() {
}
}
const connection = redisOpts()
// Each Queue/Worker gets its own fresh connection options object.
// BullMQ Workers use blocking ioredis connections internally — sharing one
// options reference across instances can cause silent failures in some versions.
const prisma = new PrismaClient({
log: ['error', 'warn'],
})
const RATE_LIMIT_MS = parseInt(process.env.WORKER_RATE_LIMIT_MS ?? '2000', 10)
const UPDATE_INTERVAL_MIN = parseInt(process.env.PRICE_UPDATE_INTERVAL_MINUTES ?? '60', 10)
const ACTIVE_HOURS = parseInt(process.env.HASHTAG_ACTIVE_HOURS ?? '24', 10)
const ZOMBIE_ZERO_COUNT = parseInt(process.env.ZOMBIE_ZERO_COUNT ?? '1000', 10)
function activeUntilFromNow(): Date {
return new Date(Date.now() + ACTIVE_HOURS * 60 * 60 * 1000)
}
// ── Queues (worker side) ──────────────────────────────────────────────────────
const priceUpdateQueue = new Queue('hashex-price-updates', { connection })
const maintenanceQueue = new Queue('hashex-maintenance', { connection })
const schedulerQueue = new Queue('hashex-scheduler', { connection })
const priceUpdateQueue = new Queue('hashex-price-updates', { connection: redisOpts() })
const maintenanceQueue = new Queue('hashex-maintenance', { 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 ───────────────────────────────────────────────────────────────────
@@ -62,8 +111,9 @@ const priceWorker = new Worker(
let postsPerHour = 0
let relatedTags: string[] = []
let displayTag: string | undefined
try {
;({ postsPerHour, relatedTags } = await getPostsData(tag))
;({ postsPerHour, relatedTags, displayTag } = await getPostsData(tag))
} catch (err) {
console.error(`[price] mastodon error for #${tag}:`, err)
throw err // BullMQ will retry
@@ -72,23 +122,60 @@ const priceWorker = new Worker(
const hashtag = await prisma.hashtag.findUnique({ where: { id: hashtagId } })
if (!hashtag) return
const now = new Date()
const ttlExpired = !hashtag.activeUntil || hashtag.activeUntil <= now
const ownerCount = await prisma.position.count({ where: { hashtagId, shares: { gt: 0 } } })
if (postsPerHour === 0) {
const newZeroCount = hashtag.zeroCount + 1
// Auto-deactivate after 3 consecutive zero-result updates with no owners
const ownerCount = await prisma.position.count({
where: { hashtagId, shares: { gt: 0 } },
})
const shouldDeactivate = newZeroCount >= 3 && ownerCount === 0
await prisma.hashtag.update({
where: { id: hashtagId },
data: {
zeroCount: newZeroCount,
isActive: shouldDeactivate ? false : hashtag.isActive,
lastUpdated: new Date(),
},
})
console.log(`[price] #${tag} got 0 posts (zeroCount=${newZeroCount})${shouldDeactivate ? ' — deactivated' : ''}`)
// Zombie threshold: retire the hashtag and force-close any remaining positions
if (newZeroCount >= ZOMBIE_ZERO_COUNT) {
if (ownerCount > 0) {
const closed = await forceClosePositions(hashtagId, hashtag.currentPrice, tag)
console.log(`[price] #${tag} zombie threshold (zeroCount=${newZeroCount}) — force-closed ${closed} position(s)`)
}
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)' : ''}`)
return
}
// If TTL expired and no holders, record final price then deactivate
if (ttlExpired && ownerCount === 0) {
const finalPrice = calcPrice(postsPerHour)
await prisma.$transaction([
prisma.hashtag.update({
where: { id: hashtagId },
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
}
@@ -101,6 +188,7 @@ const priceWorker = new Worker(
currentPrice: newPrice,
zeroCount: 0,
lastUpdated: new Date(),
...(displayTag && displayTag !== hashtag.displayTag ? { displayTag } : {}),
},
}),
prisma.priceHistory.create({
@@ -140,31 +228,182 @@ const priceWorker = new Worker(
}
},
{
connection,
connection: redisOpts(),
concurrency: 1, // one Mastodon call at a time
},
)
/**
* Daily maintenance worker — awards research points based on balance milestones.
* Daily maintenance worker — awards research points based on balance milestones,
* and prunes old price history to control data growth.
*
* Retention policy:
* - Active hashtags: keep last 7 days
* - Inactive hashtags: keep last 24 hours
*/
const maintenanceWorker = new Worker(
'hashex-maintenance',
async (job) => {
console.log(`[maintenance] running daily maintenance (job ${job.id})`)
const users = await prisma.user.findMany({ select: { id: true, balance: true } })
const MAX_RESEARCH_POINTS = 10
const users = await prisma.user.findMany({ where: { isFund: false }, select: { id: true, balance: true, researchPoints: true } })
for (const user of users) {
const points = dailyResearchPoints(user.balance)
await prisma.user.update({
where: { id: user.id },
data: { researchPoints: { increment: points } },
})
const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
if (newTotal !== user.researchPoints) {
await prisma.user.update({
where: { id: user.id },
data: { researchPoints: newTotal },
})
}
}
console.log(`[maintenance] awarded research points to ${users.length} users`)
// ── Price history pruning ──────────────────────────────────────────────
const now = new Date()
const activeDays = parseInt(process.env.PRICE_HISTORY_ACTIVE_DAYS ?? '7', 10)
const inactiveHours = parseInt(process.env.PRICE_HISTORY_INACTIVE_HOURS ?? '24', 10)
const cutoff7d = new Date(now.getTime() - activeDays * 24 * 60 * 60 * 1000)
const cutoff24h = new Date(now.getTime() - inactiveHours * 60 * 60 * 1000)
// Active hashtags: drop history older than 7 days
const activeIds = (
await prisma.hashtag.findMany({ where: { isActive: true }, select: { id: true } })
).map((h) => h.id)
const deletedActive = activeIds.length > 0
? await prisma.priceHistory.deleteMany({
where: { hashtagId: { in: activeIds }, recordedAt: { lt: cutoff7d } },
})
: { count: 0 }
// Inactive hashtags: drop history older than 24 hours
const inactiveIds = (
await prisma.hashtag.findMany({ where: { isActive: false }, select: { id: true } })
).map((h) => h.id)
const deletedInactive = inactiveIds.length > 0
? await prisma.priceHistory.deleteMany({
where: { hashtagId: { in: inactiveIds }, recordedAt: { lt: cutoff24h } },
})
: { count: 0 }
console.log(
`[maintenance] pruned price history — active: ${deletedActive.count} rows removed (>${activeDays}d), ` +
`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 },
{ 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() },
)
/**
@@ -187,53 +426,97 @@ const schedulerWorker = new Worker(
return
}
// Remove any already-waiting jobs to avoid duplicates
const waiting = await priceUpdateQueue.getJobs(['waiting', 'delayed'])
const waitingIds = new Set(waiting.map((j) => j.data?.hashtagId))
// Skip hashtags already in-flight (waiting or active) to avoid pile-up
const inFlight = await priceUpdateQueue.getJobs(['waiting', 'active', 'delayed'])
const inFlightIds = new Set(inFlight.map((j) => j.data?.hashtagId))
const toQueue = hashtags.filter((h) => !waitingIds.has(h.id))
const toQueue = hashtags.filter((h) => !inFlightIds.has(h.id))
for (const hashtag of toQueue) {
await priceUpdateQueue.add(
'update-price',
{ hashtagId: hashtag.id, tag: hashtag.tag },
{ jobId: `price-${hashtag.id}` }, // deduplicate by jobId
{
// No static jobId — avoids silent dedup drops from stale Redis hashes.
// In-flight dedup is handled by the inFlightIds check above.
removeOnComplete: true,
removeOnFail: { count: 50 },
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
},
)
}
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already waiting)`)
console.log(`[scheduler] queued ${toQueue.length} price-update jobs (${hashtags.length - toQueue.length} already in-flight)`)
},
{ connection },
{ connection: redisOpts() },
)
// ── Error handlers ────────────────────────────────────────────────────────────
for (const worker of [priceWorker, maintenanceWorker, schedulerWorker]) {
// Worker-level connection errors (separate from per-job failures)
for (const [name, worker] of [
['price', priceWorker],
['maintenance', maintenanceWorker],
['fund-nav', fundNavSnapshotWorker],
['scheduler', schedulerWorker],
] as const) {
worker.on('error', (err) => {
console.error(`[${name}-worker] connection error:`, err.message)
})
worker.on('failed', (job, err) => {
console.error(`[worker] job ${job?.id} failed:`, err.message)
console.error(`[${name}-worker] job ${job?.id} failed:`, err.message)
})
}
priceWorker.on('stalled', (jobId) => {
console.warn(`[price-worker] stalled job ${jobId} — lock expired, will retry`)
})
// ── Repeatable jobs ───────────────────────────────────────────────────────────
async function setupRepeatableJobs() {
// Always wipe existing repeatable registrations first so that:
// - stale entries from old PRICE_UPDATE_INTERVAL_MINUTES values don't persist
// - jobs exhausted by BullMQ retry limits get rescheduled cleanly
const [existingScheduler, existingMaintenance, existingFundNav] = await Promise.all([
schedulerQueue.getRepeatableJobs(),
maintenanceQueue.getRepeatableJobs(),
fundNavSnapshotQueue.getRepeatableJobs(),
])
await Promise.all([
...existingScheduler.map((j) => schedulerQueue.removeRepeatableByKey(j.key)),
...existingMaintenance.map((j) => maintenanceQueue.removeRepeatableByKey(j.key)),
...existingFundNav.map((j) => fundNavSnapshotQueue.removeRepeatableByKey(j.key)),
])
if (existingScheduler.length || existingMaintenance.length || existingFundNav.length) {
console.log(`[worker] cleared ${existingScheduler.length} scheduler + ${existingMaintenance.length} maintenance + ${existingFundNav.length} fund-nav repeatable(s)`)
}
// Price update sweep — every N minutes
await schedulerQueue.add(
'trigger-sweep',
{},
{
repeat: { every: UPDATE_INTERVAL_MIN * 60 * 1000 },
jobId: 'price-sweep-repeatable',
},
)
// Daily maintenance — every day at 00:05 UTC
// Daily maintenance — every day at 00:00 Eastern (midnight)
await maintenanceQueue.add(
'daily-maintenance',
{},
{
repeat: { pattern: '5 0 * * *' },
jobId: 'daily-maintenance-repeatable',
repeat: { pattern: '0 0 * * *' },
},
)
// Hourly fund NAV snapshot — every 15 minutes
await fundNavSnapshotQueue.add(
'fund-nav-snapshot',
{},
{
repeat: { pattern: '*/15 * * * *' },
},
)
@@ -256,6 +539,7 @@ async function shutdown() {
console.log('[worker] shutting down…')
await priceWorker.close()
await maintenanceWorker.close()
await fundNavSnapshotWorker.close()
await schedulerWorker.close()
await prisma.$disconnect()
process.exit(0)
+1 -1
View File
File diff suppressed because one or more lines are too long