-
- 1st on lb:
- 1.75x bounty
-
-
- 2nd on lb:
- 1.5x bounty
-
-
-
3rd on lb:
-
1.25x bounty
+
+
+
+
+ Base Rates
+
+
+
+ Desktop, Mobile, Other
+ 1.5 🍪
+
+
+ CLI, Games, Hardware
+ 1.0 🍪
+
+
+ Web Apps, Bots
+ 0.6 🍪
+
-
- 4th+ on lb:
- 1x bounty
+
+
+
+
+ Multipliers
+
+
+
+
+
+ First Review
+
+
+ 1.5x for your 1st review of the day
+
+
+
1.5x
+
+
+
+
+ Old Projects
+
+
+ 1.5x if >7 days, 1.2x if >24h
+
+
+
1.5x
+
+
+
+ Daily Grind
+
+ 1.2x after 7 reviews, 1.3x after 15
+
+
+
1.3x
+
+
+
+ Weekly Rank
+
+ 1.75x (1st), 1.5x (2nd), 1.25x (3rd)
+
+
+
1.75x
+
+
+
+
+ Base × Multipliers = Total.{' '}
+
+
diff --git a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
index d46b82b..c0073d6 100644
--- a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
+++ b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
@@ -235,9 +235,18 @@ export const PATCH = withParams(PERMS.certs_edit)(async ({ user, req, params, ip
}
if (verdict.toLowerCase() === 'approved' || verdict.toLowerCase() === 'rejected') {
- const payout = await calc(user.id, cert.projectType, cert.customBounty)
+ const payout = await calc({
+ userId: user.id,
+ projectType: cert.projectType,
+ verdict: verdict.toLowerCase() as 'approved' | 'rejected',
+ certCreatedAt: cert.createdAt,
+ customBounty: cert.customBounty,
+ })
updateData.cookiesEarned = payout.cookies
- updateData.payoutMulti = payout.multi
+
+ const effectiveMulti =
+ payout.base > 0 ? (payout.cookies - (payout.customBounty || 0)) / payout.base : 0
+ updateData.payoutMulti = Number(effectiveMulti.toFixed(2))
}
}
diff --git a/sw-dash/src/components/admin/chart.tsx b/sw-dash/src/components/admin/chart.tsx
index 58e5383..d705a81 100644
--- a/sw-dash/src/components/admin/chart.tsx
+++ b/sw-dash/src/components/admin/chart.tsx
@@ -1,128 +1,128 @@
'use client'
import {
- LineChart,
- Line,
- BarChart,
- Bar,
- XAxis,
- YAxis,
- Tooltip,
- ResponsiveContainer,
- CartesianGrid,
+ LineChart,
+ Line,
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ResponsiveContainer,
+ CartesianGrid,
} from 'recharts'
interface Props {
- data: Array<{ date?: string; label?: string; value: number;[key: string]: any }>
- type?: 'line' | 'bar'
- dataKey?: string
- xKey?: string
- yLabel?: string
- xLabel?: string
- valueLabel?: string
- color?: string
+ data: Array<{ date?: string; label?: string; value: number; [key: string]: any }>
+ type?: 'line' | 'bar'
+ dataKey?: string
+ xKey?: string
+ yLabel?: string
+ xLabel?: string
+ valueLabel?: string
+ color?: string
}
export function Chart({
- data,
- type = 'line',
- dataKey = 'value',
- xKey = 'date',
- yLabel = 'Value',
- xLabel = 'Date',
- valueLabel = 'Value',
- color = '#f59e0b',
+ data,
+ type = 'line',
+ dataKey = 'value',
+ xKey = 'date',
+ yLabel = 'Value',
+ xLabel = 'Date',
+ valueLabel = 'Value',
+ color = '#f59e0b',
}: Props) {
- const formatDate = (dateStr: string) => {
- const date = new Date(dateStr)
- const month = date.toLocaleString('en-US', { month: 'short' })
- const day = date.getDate()
- return `${month} ${day}`
- }
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr)
+ const month = date.toLocaleString('en-US', { month: 'short' })
+ const day = date.getDate()
+ return `${month} ${day}`
+ }
- const CustomTooltip = ({ active, payload }: any) => {
- if (!active || !payload || !payload.length) return null
+ const CustomTooltip = ({ active, payload }: any) => {
+ if (!active || !payload || !payload.length) return null
- const date = payload[0].payload[xKey]
- const value = payload[0].value
+ const date = payload[0].payload[xKey]
+ const value = payload[0].value
- return (
-
-
- {date ? formatDate(date) : ''}
-
-
- {valueLabel}: {value}
-
-
- )
- }
+ return (
+
+
+ {date ? formatDate(date) : ''}
+
+
+ {valueLabel}: {value}
+
+
+ )
+ }
- const formatXAxis = (value: string) => {
- return formatDate(value)
- }
+ const formatXAxis = (value: string) => {
+ return formatDate(value)
+ }
- return (
-
- {type === 'line' ? (
-
-
-
-
- } cursor={false} />
+ return (
+
+ {type === 'line' ? (
+
+
+
+
+ } cursor={false} />
-
- ) : (
-
-
-
-
- } cursor={false} />
-
-
- )}
-
- )
+
+ ) : (
+
+
+
+
+ } cursor={false} />
+
+
+ )}
+
+ )
}
diff --git a/sw-dash/src/lib/payouts.ts b/sw-dash/src/lib/payouts.ts
index 4173fb6..ae96418 100644
--- a/sw-dash/src/lib/payouts.ts
+++ b/sw-dash/src/lib/payouts.ts
@@ -1,31 +1,80 @@
import { prisma } from './db'
const RATES: Record
= {
- 'Web App': 0.75,
- 'Chat Bot': 0.75,
- Extension: 0.94,
+ 'Desktop App (Windows)': 1.5,
+ 'Desktop App (Linux)': 1.5,
+ 'Desktop App (macOS)': 1.5,
+ 'Android App': 1.5,
+ 'iOS App': 1.5,
+ Other: 1.5,
CLI: 1,
Cargo: 1,
- 'Desktop App (Windows)': 1.25,
'Minecraft Mods': 1,
- 'Android App': 1,
- 'iOS App': 1,
'Steam Games': 1,
PyPI: 1,
- 'Desktop App (Linux)': 1.4,
- 'Desktop App (macOS)': 1.4,
- Hardware: 1.4,
- Other: 1.4,
+ Hardware: 1,
+ Extension: 1,
+ 'Web App': 0.6,
+ 'Chat Bot': 0.6,
}
-const MULTI = [1.75, 1.5, 1.25]
+const RANK_MULTI = [1.75, 1.5, 1.25]
export function getBounty(type: string | null): number {
if (!type) return 1
return RATES[type] ?? 1
}
-export async function getMulti(userId: number): Promise {
+function getESTComponents(date: Date) {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'America/New_York',
+ year: 'numeric',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ hourCycle: 'h23',
+ }).formatToParts(date)
+ const getVal = (type: string) => parseInt(parts.find((p) => p.type === type)?.value || '0')
+ return { y: getVal('year'), m: getVal('month') - 1, d: getVal('day'), h: getVal('hour') }
+}
+
+function getStartOfTodayUTC(): Date {
+ const now = new Date()
+ const { y, m, d } = getESTComponents(now)
+ const cand1 = new Date(Date.UTC(y, m, d, 5, 0, 0, 0))
+ const cand2 = new Date(Date.UTC(y, m, d, 4, 0, 0, 0))
+ const check1 = getESTComponents(cand1)
+ return check1.h !== 0 ? cand2 : cand1
+}
+
+type WaitTier = 'new' | 'normal' | 'old' | 'ancient'
+
+function classifyWait(createdAt: Date): WaitTier {
+ const ageMs = Date.now() - createdAt.getTime()
+ const ageH = ageMs / (1000 * 60 * 60)
+ if (ageH < 8) return 'new'
+ if (ageH <= 24) return 'normal'
+ if (ageH <= 7 * 24) return 'old'
+ return 'ancient'
+}
+
+async function getWaitMulti(tier: WaitTier): Promise {
+ if (tier === 'normal') return 1
+ if (tier === 'old') return 1.2
+ if (tier === 'ancient') return 1.5
+
+ // For "new" projects, only penalise if there are ≥ 7 old/ancient pending
+ const eightHoursAgo = new Date(Date.now() - 8 * 60 * 60 * 1000)
+ const oldCount = await prisma.shipCert.count({
+ where: {
+ status: 'pending',
+ createdAt: { lt: eightHoursAgo },
+ },
+ })
+ return oldCount >= 7 ? 0.8 : 1
+}
+
+async function getRankMulti(userId: number): Promise {
const now = new Date()
const day = now.getDay()
const weekStart = new Date(now)
@@ -62,16 +111,86 @@ export async function getMulti(userId: number): Promise {
const pos = lb.findIndex((r) => r.reviewerId === userId)
if (pos < 0) return 1
- if (pos < 3) return MULTI[pos]
+ if (pos < 3) return RANK_MULTI[pos]
return 1
}
-export async function calc(userId: number, type: string | null, customBounty?: number | null) {
- const bounty = getBounty(type)
- const multi = await getMulti(userId)
- const base = bounty * multi
- const total = customBounty ? base + customBounty : base
- return { cookies: total, multi, bounty, customBounty: customBounty || 0 }
+async function getStreakMulti(userId: number): Promise {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ select: { streak: true },
+ })
+ if (!user || user.streak <= 0) return 1
+ return 1 + user.streak * 0.05
+}
+
+async function getDailyMulti(userId: number): Promise {
+ const startOfToday = getStartOfTodayUTC()
+
+ const todayCount = await prisma.shipCert.count({
+ where: {
+ reviewerId: userId,
+ status: { in: ['approved', 'rejected'] },
+ reviewCompletedAt: { gte: startOfToday },
+ },
+ })
+
+ if (todayCount === 0) return 1.5 // first review of the day
+ if (todayCount >= 15) return 1.3
+ if (todayCount >= 7) return 1.2
+ return 1
+}
+
+export interface CalcInput {
+ userId: number
+ projectType: string | null
+ verdict: 'approved' | 'rejected'
+ certCreatedAt: Date
+ customBounty?: number | null
+}
+
+export interface CalcResult {
+ cookies: number
+ base: number
+ waitMulti: number
+ verdictMulti: number
+ rankMulti: number
+ streakMulti: number
+ dailyMulti: number
+ customBounty: number
+}
+
+export async function calc(input: CalcInput): Promise {
+ const { userId, projectType, verdict, certCreatedAt, customBounty } = input
+
+ const base = getBounty(projectType)
+
+ const waitTier = classifyWait(certCreatedAt)
+ const waitMulti = await getWaitMulti(waitTier)
+
+ const verdictMulti = verdict === 'rejected' ? 0.8 : 1
+
+ const rankMulti = await getRankMulti(userId)
+ const streakMulti = 1 // unused for now
+
+ const dailyMulti = await getDailyMulti(userId)
+
+ const computed = base * waitMulti * verdictMulti * rankMulti * dailyMulti
+
+ const flat = customBounty || 0
+ const total = computed + flat
+
+ return {
+ cookies: total,
+ base,
+ waitMulti,
+ verdictMulti,
+ rankMulti,
+ streakMulti,
+ dailyMulti,
+ customBounty: flat,
+ }
}
export { RATES }
+export { getRankMulti as getMulti }