feat: implement lottery win feature and update related components
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m18s
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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([
|
||||||
where: { id: user.id },
|
prisma.user.update({
|
||||||
data: {
|
where: { id: user.id },
|
||||||
balance: { increment: winAmount },
|
data: {
|
||||||
lastLotteryAt: now,
|
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({
|
return NextResponse.json({
|
||||||
box,
|
box,
|
||||||
|
|||||||
+14
-1
@@ -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}
|
||||||
|
|||||||
@@ -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-emerald-500/15 text-emerald-400'
|
? 'bg-amber-500/15 text-amber-400'
|
||||||
: 'bg-red-500/15 text-red-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>
|
||||||
<Link
|
{isLottery ? (
|
||||||
href={`/hashtag/${t.hashtag.tag}`}
|
<span className="text-amber-300">Lucky Dip</span>
|
||||||
className="hover:text-indigo-300"
|
) : (
|
||||||
>
|
<Link
|
||||||
#{t.hashtag.displayTag}
|
href={`/hashtag/${t.hashtag!.tag}`}
|
||||||
</Link>
|
className="hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
#{t.hashtag!.displayTag}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
{isLottery ? (
|
||||||
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
<p className="text-emerald-400 font-medium">{formatCurrency(t.profit)}</p>
|
||||||
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
) : (
|
||||||
{formatPnl(t.profit)}
|
<>
|
||||||
</p>
|
<p>{formatNumber(t.shares)} sh @ {formatCurrency(t.price)}</p>
|
||||||
|
{(t.type === 'SELL_LONG' || t.type === 'SELL_SHORT') && (
|
||||||
|
<p className={`text-xs ${pnlColor(t.profit)}`}>
|
||||||
|
{formatPnl(t.profit)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user