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 { 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
@@ -86,11 +85,24 @@ 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">
@@ -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 */}
+23 -3
View File
@@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { notFound } from 'next/navigation'
import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils'
import { getBalanceTier } from '@/lib/pricing'
import Link from 'next/link'
import { TrendingUp, TrendingDown, Coins } from 'lucide-react'
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>
)}
{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="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 className="text-right">
+14
View File
@@ -25,6 +25,20 @@ 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 }
}
/**
* 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) => {
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) {
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`)