feat: implement lottery win feature and update related components
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s

This commit is contained in:
2026-03-18 18:56:11 -04:00
parent af5484f0cd
commit 6b32b28af1
7 changed files with 82 additions and 85 deletions
-53
View File
@@ -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 ### Other Ideas / Nice-to-Haves
- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades. - **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades.
+5 -4
View File
@@ -105,13 +105,13 @@ model Trade {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
hashtagId String hashtagId String?
hashtag Hashtag @relation(fields: [hashtagId], references: [id]) hashtag Hashtag? @relation(fields: [hashtagId], references: [id])
type TradeType type TradeType
shares Float 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 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()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@ -129,4 +129,5 @@ enum TradeType {
SELL_LONG SELL_LONG
BUY_SHORT BUY_SHORT
SELL_SHORT SELL_SHORT
LOTTERY_WIN
} }
+1
View File
@@ -29,6 +29,7 @@ services:
MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}" MASTODON_ACCESS_TOKEN: "${MASTODON_ACCESS_TOKEN}"
WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}" WORKER_RATE_LIMIT_MS: "${WORKER_RATE_LIMIT_MS:-2000}"
PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}" PRICE_UPDATE_INTERVAL_MINUTES: "${PRICE_UPDATE_INTERVAL_MINUTES:-60}"
HASHTAG_ACTIVE_HOURS: "${HASHTAG_ACTIVE_HOURS:-24}"
MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}" MAX_PAGES_PER_HASHTAG: "${MAX_PAGES_PER_HASHTAG:-5}"
depends_on: depends_on:
postgres: postgres:
+7 -3
View File
@@ -71,13 +71,17 @@ export default async function AdminOverviewPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`text-xs px-1.5 py-0.5 rounded ${ 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 === 'LOTTERY_WIN'
? 'bg-amber-500/15 text-amber-400'
: t.type.startsWith('BUY') ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`} }`}
> >
{t.type.replace('_', ' ')} {t.type.replace(/_/g, ' ')}
</span> </span>
<span className="text-slate-300">{t.user.username}</span> <span className="text-slate-300">{t.user.username}</span>
<span className="text-slate-500">#{t.hashtag.displayTag}</span> <span className="text-slate-500">
{t.hashtag ? `#${t.hashtag.displayTag}` : 'Lucky Dip'}
</span>
</div> </div>
<span>{formatCurrency(t.total)}</span> <span>{formatCurrency(t.total)}</span>
</div> </div>
+18 -2
View File
@@ -64,13 +64,29 @@ export async function POST(req: NextRequest) {
const winAmount = prizes[box] const winAmount = prizes[box]
await prisma.user.update({ await prisma.$transaction([
prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
balance: { increment: winAmount }, balance: { increment: winAmount },
lastLotteryAt: now, 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({ return NextResponse.json({
box, box,
+14 -1
View File
@@ -28,6 +28,7 @@ async function getStats() {
}), }),
// Recently traded // Recently traded
prisma.trade.findMany({ prisma.trade.findMany({
where: { hashtagId: { not: null } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, take: 8,
include: { hashtag: true }, include: { hashtag: true },
@@ -90,6 +91,18 @@ export default async function HomePage() {
> >
Lucky Dip Lucky Dip
</Link> </Link>
<Link
href="/stocks"
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
>
Markets
</Link>
<Link
href="/positions"
className="bg-surface-card border border-surface-border hover:border-indigo-500/50 px-6 py-2.5 rounded-lg font-medium transition-colors"
>
My Positions
</Link>
</> </>
) : ( ) : (
<> <>
@@ -182,7 +195,7 @@ export default async function HomePage() {
<section> <section>
<h2 className="text-lg font-semibold mb-4">Recently traded</h2> <h2 className="text-lg font-semibold mb-4">Recently traded</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{recentTrades.map(({ hashtag }) => ( {recentTrades.map(({ hashtag }) => hashtag && (
<HashtagCard <HashtagCard
key={hashtag.id} key={hashtag.id}
tag={hashtag.tag} tag={hashtag.tag}
+21 -6
View File
@@ -167,35 +167,50 @@ export default async function ProfilePage({ params }: Props) {
</h2> </h2>
<div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden"> <div className="bg-surface-card border border-surface-border rounded-xl overflow-hidden">
<div className="divide-y divide-surface-border"> <div className="divide-y divide-surface-border">
{user.trades.map((t) => ( {user.trades.map((t) => {
const isLottery = t.type === 'LOTTERY_WIN'
return (
<div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm"> <div key={t.id} className="flex items-center justify-between px-4 py-3 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-xs font-medium px-2 py-0.5 rounded ${ className={`text-xs font-medium px-2 py-0.5 rounded ${
t.type.startsWith('BUY') isLottery
? 'bg-amber-500/15 text-amber-400'
: t.type.startsWith('BUY')
? 'bg-emerald-500/15 text-emerald-400' ? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400' : 'bg-red-500/15 text-red-400'
}`} }`}
> >
{t.type.replace('_', ' ')} {t.type.replace(/_/g, ' ')}
</span> </span>
{isLottery ? (
<span className="text-amber-300">Lucky Dip</span>
) : (
<Link <Link
href={`/hashtag/${t.hashtag.tag}`} href={`/hashtag/${t.hashtag!.tag}`}
className="hover:text-indigo-300" className="hover:text-indigo-300"
> >
#{t.hashtag.displayTag} #{t.hashtag!.displayTag}
</Link> </Link>
)}
</div> </div>
<div className="text-right"> <div className="text-right">
{isLottery ? (
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
) : (
<>
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p> <p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && ( {(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
<p className={`text-xs ${pnlColor(t.profit)}`}> <p className={`text-xs ${pnlColor(t.profit)}`}>
{formatPnl(t.profit)} {formatPnl(t.profit)}
</p> </p>
)} )}
</>
)}
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</div> </div>
</section> </section>