diff --git a/sw-dash/src/app/admin/payouts/logs/page.tsx b/sw-dash/src/app/admin/payouts/logs/page.tsx index 4125ec3..7d5e8b4 100644 --- a/sw-dash/src/app/admin/payouts/logs/page.tsx +++ b/sw-dash/src/app/admin/payouts/logs/page.tsx @@ -58,8 +58,8 @@ export default async function Logs() { project reviewer type - rate - multi + base rate + eff. multi when cookies diff --git a/sw-dash/src/app/admin/ship_certifications/mystats/page.tsx b/sw-dash/src/app/admin/ship_certifications/mystats/page.tsx index 7f29e2b..441bd0b 100644 --- a/sw-dash/src/app/admin/ship_certifications/mystats/page.tsx +++ b/sw-dash/src/app/admin/ship_certifications/mystats/page.tsx @@ -225,42 +225,88 @@ export default async function Stats() { -
-

Bounty Rates

-
- {Object.entries(RATES) - .sort((a, b) => b[1] - a[1]) - .map(([type, bounty]) => ( -
- {type} - {bounty} 🍪 -
- ))} +
+
+

+ Dynamic Payouts +

-
-
Multipliers
-
-
- 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 }