feat: implement balance tier system and update research points allocation in maintenance worker

This commit is contained in:
2026-03-18 19:28:29 -04:00
parent 45d3c62cae
commit 1ce1511954
4 changed files with 80 additions and 29 deletions
+34 -21
View File
@@ -5,8 +5,7 @@ import { Loader2, Dices, CheckCircle2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils' import { formatCurrency } from '@/lib/utils'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Link from 'next/link' import Link from 'next/link'
import { GRID_SIZE, PRIZE_MAP } from '@/lib/lottery'
const GRID_SIZE = 25
type PickResult = { type PickResult = {
box: number box: number
@@ -86,11 +85,24 @@ export default function LotteryPage() {
) )
} }
const prizeLabels: Record<number, string> = { const PRIZE_EMOJIS = ['🏆', '🥈', '🎁', '✨', '⭐']
200: '🏆 $200', const uniquePrizes = [...new Set(Object.values(PRIZE_MAP))].filter(v => v > 0).sort((a, b) => b - a)
50: '🥈 $50', const prizeCounts = uniquePrizes.reduce<Record<number, number>>((acc, amt) => {
10: '🎁 $10', 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 ( return (
<div className="max-w-xl mx-auto space-y-8"> <div className="max-w-xl mx-auto space-y-8">
@@ -107,21 +119,22 @@ export default function LotteryPage() {
{/* Prize table */} {/* Prize table */}
<div className="bg-surface-card border border-surface-border rounded-xl p-4"> <div className="bg-surface-card border border-surface-border rounded-xl p-4">
<p className="text-xs text-slate-500 mb-3 font-medium uppercase tracking-wider">Prize pool</p> <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
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-2"> className="grid gap-2 text-sm text-center"
<p className="font-bold text-amber-400">$200</p> style={{ gridTemplateColumns: `repeat(${uniquePrizes.length}, 1fr)` }}
<p className="text-xs text-slate-500">×1 box</p> >
</div> {uniquePrizes.map((amt, i) => {
<div className="bg-slate-500/10 border border-slate-500/20 rounded-lg p-2"> const color = TIER_COLORS[i] ?? TIER_COLORS[TIER_COLORS.length - 1]
<p className="font-bold text-slate-300">$50</p> const count = prizeCounts[amt]
<p className="text-xs text-slate-500">×2 boxes</p> return (
</div> <div key={amt} className={`${color.bg} border ${color.border} rounded-lg p-2`}>
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg p-2"> <p className={`font-bold ${color.text}`}>{formatCurrency(amt)}</p>
<p className="font-bold text-emerald-400">$10</p> <p className="text-xs text-slate-500">×{count} box{count !== 1 ? 'es' : ''}</p>
<p className="text-xs text-slate-500">×3 boxes</p> </div>
</div> )
})}
</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> </div>
{/* Result banner */} {/* Result banner */}
+23 -3
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils' import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import { getBalanceTier } from '@/lib/pricing'
import Link from 'next/link' import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react' import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
import ChangePasswordForm from './ChangePasswordForm' import ChangePasswordForm from './ChangePasswordForm'
@@ -74,9 +75,28 @@ export default async function ProfilePage({ params }: Props) {
<p className="text-slate-500 text-sm">@{user.username}</p> <p className="text-slate-500 text-sm">@{user.username}</p>
)} )}
{isOwn && ( {isOwn && (
<p className="text-slate-400 text-sm mt-1"> <div className="mt-1 text-sm text-slate-400 space-y-0.5">
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available {(() => {
</p> 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="text-slate-600 text-xs ml-1.5">
(next tier at {formatCurrency(tier.nextThreshold)})
</span>
)}
</p>
<p>
{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available
</p>
</>
)
})()}
</div>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right">
+14
View File
@@ -25,6 +25,20 @@ export function dailyResearchPoints(balance: number): number {
return 1 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 }
}
/** /**
* Calculate the cost/proceeds and realized P&L for a trade. * Calculate the cost/proceeds and realized P&L for a trade.
* *
+9 -5
View File
@@ -170,13 +170,17 @@ const maintenanceWorker = new Worker(
async (job) => { async (job) => {
console.log(`[maintenance] running daily maintenance (job ${job.id})`) 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({ select: { id: true, balance: true, researchPoints: true } })
for (const user of users) { for (const user of users) {
const points = dailyResearchPoints(user.balance) const points = dailyResearchPoints(user.balance)
await prisma.user.update({ const newTotal = Math.min(user.researchPoints + points, MAX_RESEARCH_POINTS)
where: { id: user.id }, if (newTotal !== user.researchPoints) {
data: { researchPoints: { increment: points } }, await prisma.user.update({
}) where: { id: user.id },
data: { researchPoints: newTotal },
})
}
} }
console.log(`[maintenance] awarded research points to ${users.length} users`) console.log(`[maintenance] awarded research points to ${users.length} users`)