diff --git a/README.md b/README.md index e9efc47..84dd0d4 100644 --- a/README.md +++ b/README.md @@ -329,59 +329,6 @@ The items below are planned improvements roughly ordered by user value. They are --- -### 1. Auto-Derive Display Casing from Mastodon Posts - -**Problem:** `displayTag` is currently set once at research/creation time from whatever the user typed. In reality Mastodon users have an established "canonical" capitalisation for a tag (e.g. `#StPatricksDay` rather than `#stpatricksday`) and our display tag should reflect that. - -**Plan:** -- During each price update job the worker already fetches up to 200 posts. The Mastodon API returns each post's `tags` array where `name` contains the tag as typed by the poster (e.g. `StPatricksDay`). -- Count the frequency of each distinct casing variant seen across all fetched posts for the hashtag being updated. -- If the most frequent variant differs from the current `displayTag`, update `Hashtag.displayTag` as part of the price update transaction. -- Only update when a variant accounts for a meaningful majority (e.g. ≥ 50% of occurrences) to avoid flip-flopping on low-signal posts. -- Implementation touches: `mastodon.ts` already returns `post.tags` from `getPostsData()` — add a `casing` field to the result (`{ postsPerHour, relatedTags, displayTag? }`); worker checks the returned `displayTag` and includes it in the Prisma update when it differs. -- No schema changes required — `displayTag` already exists on `Hashtag`. - ---- - -### 2. Home Page Holdings Summary (Signed-In) - -**Problem:** Signed-in users land on the home page and see only the generic trending list — there is no personalised hook to show how their portfolio is performing. - -**Plan:** -- Add two summary cards above the "Trending now" section, visible only to signed-in users: **Biggest Gain** and **Biggest Loss** (by unrealised P&L across open positions). -- Both cards link to the relevant hashtag page. -- Implemented as a server component addition to `src/app/page.tsx`: query the current user's open positions (joined with current price) and compute unrealised P&L per position to find the top and bottom. -- If the user has no positions, the cards are omitted (no empty-state clutter). -- No schema changes required. - ---- - -### 3. Search Autocomplete - -**Problem:** The current search/research input is a plain text field with no discovery aid — users must know or guess full hashtag names. - -**Plan:** -- While the user types in the search box, show a dropdown of matching hashtags that are **already tracked** on the exchange (fetched from a new `GET /api/hashtags/search?q=` endpoint). -- The autocomplete only surfaces existing active hashtags; submitting a tag that doesn't appear in the list still proceeds through the normal research flow (no interference with new-hashtag discovery). -- Client-side: debounce the input (~300 ms), cancel in-flight requests on new keystrokes, close dropdown on `Escape` or outside click. -- Endpoint: case-insensitive prefix match on `Hashtag.tag` where `isActive = true` and `isBanned = false`, returning `{ tag, displayTag, currentPrice }` for up to 8 results. -- No schema changes required. - ---- - -### 4. Open Positions Full Page - -**Problem:** The profile page shows open positions in a compact list — there is no space for richer per-position data (cost basis, current value, a mini P&L chart). - -**Plan:** -- New page `/positions` (auth-protected) showing only the signed-in user's open positions in a more detailed layout. -- Each row / card: hashtag name + link, position type (LONG/SHORT), shares held, average buy price, current price, total cost basis, current value, unrealised P&L and P&L %, a sparkline chart of the hashtag's recent price history. -- Link into this page from the "Open positions" heading on the profile page. -- No new nav item needed — accessed via profile page link. -- No schema changes required. - ---- - ### Other Ideas / Nice-to-Haves - **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d87c98..7f03ab6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,13 +105,13 @@ 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]) 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]) @@ -129,4 +129,5 @@ enum TradeType { SELL_LONG BUY_SHORT SELL_SHORT + LOTTERY_WIN } diff --git a/prod-compose.yml b/prod-compose.yml index 1ef09a5..c31b17a 100644 --- a/prod-compose.yml +++ b/prod-compose.yml @@ -29,6 +29,7 @@ services: MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}" WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}" PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}" + HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}" MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}" depends_on: postgres: diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 155d6ab..cc1d3df 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -71,13 +71,17 @@ export default async function AdminOverviewPage() {
- {t.type.replace('_', ' ')} + {t.type.replace(/_/g, ' ')} {t.user.username} - #{t.hashtag.displayTag} + + {t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'} +
{formatCurrency(t.total)} diff --git a/src/app/api/lottery/pick/route.ts b/src/app/api/lottery/pick/route.ts index f9f695a..bec2a13 100644 --- a/src/app/api/lottery/pick/route.ts +++ b/src/app/api/lottery/pick/route.ts @@ -64,13 +64,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: 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, diff --git a/src/app/page.tsx b/src/app/page.tsx index 81543e1..b6ec65f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,6 +28,7 @@ async function getStats() { }), // Recently traded prisma.trade.findMany({ + where: { hashtagId: { not: null } }, orderBy: { createdAt: 'desc' }, take: 8, include: { hashtag: true }, @@ -90,6 +91,18 @@ export default async function HomePage() { > Lucky Dip + + Markets + + + My Positions + ) : ( <> @@ -182,7 +195,7 @@ export default async function HomePage() {

Recently traded

- {recentTrades.map(({ hashtag }) => ( + {recentTrades.map(({ hashtag }) => hashtag && (
- {user.trades.map((t) => ( + {user.trades.map((t) => { + const isLottery = t.type === 'LOTTERY_WIN' + return (
- {t.type.replace('_', ' ')} + {t.type.replace(/_/g, ' ')} - - #{t.hashtag.displayTag} - + {isLottery ? ( + Lucky Dip + ) : ( + + #{t.hashtag!.displayTag} + + )}
-

{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}

- {(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && ( -

- {formatPnl(t.profit)} -

+ {isLottery ? ( +

{formatCurrency(t.profit)}

+ ) : ( + <> +

{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}

+ {(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && ( +

+ {formatPnl(t.profit)} +

+ )} + )}
- ))} + ) + })}