mirror of
https://github.com/ershisan99/www.git
synced 2025-12-17 05:19:23 +00:00
add season-specific leaderboard and filtering functionality
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import type { SelectGames } from '@/server/db/types'
|
import type { SelectGames } from '@/server/db/types'
|
||||||
|
import { type Season, filterGamesBySeason, getSeasonDisplayName } from '@/shared/seasons'
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
@@ -23,8 +24,17 @@ const chartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
export function MmrTrendChart({ games }: { games: SelectGames[] }) {
|
export function MmrTrendChart({
|
||||||
const chartData = games
|
games,
|
||||||
|
season = 'all'
|
||||||
|
}: {
|
||||||
|
games: SelectGames[],
|
||||||
|
season?: Season
|
||||||
|
}) {
|
||||||
|
// Filter games by season if a specific season is selected
|
||||||
|
const seasonFilteredGames = filterGamesBySeason(games, season)
|
||||||
|
|
||||||
|
const chartData = seasonFilteredGames
|
||||||
.filter((game) => game.gameType === 'ranked')
|
.filter((game) => game.gameType === 'ranked')
|
||||||
.map((game) => ({
|
.map((game) => ({
|
||||||
date: game.gameTime,
|
date: game.gameTime,
|
||||||
@@ -44,7 +54,7 @@ export function MmrTrendChart({ games }: { games: SelectGames[] }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>MMR Trends</CardTitle>
|
<CardTitle>MMR Trends</CardTitle>
|
||||||
<CardDescription>All time</CardDescription>
|
<CardDescription>{getSeasonDisplayName(season)}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={'p-2'}>
|
<CardContent className={'p-2'}>
|
||||||
<ChartContainer config={chartConfig}>
|
<ChartContainer config={chartConfig}>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import type { SelectGames } from '@/server/db/types'
|
import type { SelectGames } from '@/server/db/types'
|
||||||
|
import { type Season, filterGamesBySeason, getSeasonDisplayName } from '@/shared/seasons'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
@@ -25,11 +26,20 @@ const chartConfig = {
|
|||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
export function WinrateTrendChart({ games }: { games: SelectGames[] }) {
|
export function WinrateTrendChart({
|
||||||
|
games,
|
||||||
|
season = 'all'
|
||||||
|
}: {
|
||||||
|
games: SelectGames[],
|
||||||
|
season?: Season
|
||||||
|
}) {
|
||||||
const [gamesWindow, setGamesWindow] = useState(30)
|
const [gamesWindow, setGamesWindow] = useState(30)
|
||||||
|
|
||||||
|
// Filter games by season if a specific season is selected
|
||||||
|
const seasonFilteredGames = filterGamesBySeason(games, season)
|
||||||
|
|
||||||
// Sort games by date (oldest to newest)
|
// Sort games by date (oldest to newest)
|
||||||
const sortedGames = [...games]
|
const sortedGames = [...seasonFilteredGames]
|
||||||
.sort((a, b) => a.gameTime.getTime() - b.gameTime.getTime())
|
.sort((a, b) => a.gameTime.getTime() - b.gameTime.getTime())
|
||||||
.filter((game) => game.result === 'win' || game.result === 'loss')
|
.filter((game) => game.result === 'win' || game.result === 'loss')
|
||||||
|
|
||||||
@@ -41,7 +51,7 @@ export function WinrateTrendChart({ games }: { games: SelectGames[] }) {
|
|||||||
<CardHeader className='flex flex-row items-center justify-between'>
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Winrate Trends</CardTitle>
|
<CardTitle>Winrate Trends</CardTitle>
|
||||||
<CardDescription>Rolling winrate over time</CardDescription>
|
<CardDescription>{getSeasonDisplayName(season)}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex w-[200px] flex-col gap-2'>
|
<div className='flex w-[200px] flex-col gap-2'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
|
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
|
||||||
|
import {
|
||||||
|
type Season,
|
||||||
|
filterGamesBySeason,
|
||||||
|
getSeasonDisplayName,
|
||||||
|
} from '@/shared/seasons'
|
||||||
import { api } from '@/trpc/react'
|
import { api } from '@/trpc/react'
|
||||||
import {
|
import {
|
||||||
ArrowDownCircle,
|
ArrowDownCircle,
|
||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Filter,
|
Filter,
|
||||||
@@ -62,6 +68,7 @@ function UserInfoComponent() {
|
|||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const format = useFormatter()
|
const format = useFormatter()
|
||||||
const timeZone = useTimeZone()
|
const timeZone = useTimeZone()
|
||||||
|
const [season, setSeason] = useState<Season>('season3')
|
||||||
|
|
||||||
const [leaderboardFilter, setLeaderboardFilter] = useState('all')
|
const [leaderboardFilter, setLeaderboardFilter] = useState('all')
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -74,29 +81,61 @@ function UserInfoComponent() {
|
|||||||
user_id: id,
|
user_id: id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch current season data
|
||||||
const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({
|
const [rankedLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery({
|
||||||
channel_id: RANKED_CHANNEL,
|
channel_id: RANKED_CHANNEL,
|
||||||
|
season,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery(
|
const [vanillaLeaderboard] = api.leaderboard.get_leaderboard.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
channel_id: VANILLA_CHANNEL,
|
channel_id: VANILLA_CHANNEL,
|
||||||
|
season,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fetch current season user rank
|
||||||
const [vanillaUserRankQ] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
const [vanillaUserRankQ] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
||||||
channel_id: VANILLA_CHANNEL,
|
channel_id: VANILLA_CHANNEL,
|
||||||
user_id: id,
|
user_id: id,
|
||||||
|
season,
|
||||||
})
|
})
|
||||||
const [rankedUserRankQ] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
const [rankedUserRankQ] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
||||||
channel_id: RANKED_CHANNEL,
|
channel_id: RANKED_CHANNEL,
|
||||||
user_id: id,
|
user_id: id,
|
||||||
|
season,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch Season 2 data for historic comparison
|
||||||
|
const [rankedUserRankS2Q] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
||||||
|
channel_id: RANKED_CHANNEL,
|
||||||
|
user_id: id,
|
||||||
|
season: 'season2',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch Season 3 data for historic comparison
|
||||||
|
const [rankedUserRankS3Q] = api.leaderboard.get_user_rank.useSuspenseQuery({
|
||||||
|
channel_id: RANKED_CHANNEL,
|
||||||
|
user_id: id,
|
||||||
|
season: 'season3',
|
||||||
})
|
})
|
||||||
const rankedUserRank = rankedUserRankQ?.data
|
const rankedUserRank = rankedUserRankQ?.data
|
||||||
const vanillaUserRank = vanillaUserRankQ?.data
|
const vanillaUserRank = vanillaUserRankQ?.data
|
||||||
|
|
||||||
|
// Extract historic data
|
||||||
|
const rankedUserRankS2 = rankedUserRankS2Q?.data
|
||||||
|
const rankedUserRankS3 = rankedUserRankS3Q?.data
|
||||||
|
|
||||||
|
// Determine which historic data to show (opposite of current season)
|
||||||
|
const historicRankedData =
|
||||||
|
season === 'season2' ? rankedUserRankS3 : rankedUserRankS2
|
||||||
|
// Filter games by season
|
||||||
|
const seasonFilteredGames = filterGamesBySeason(games, season)
|
||||||
|
|
||||||
const filteredGamesByLeaderboard =
|
const filteredGamesByLeaderboard =
|
||||||
leaderboardFilter === 'all'
|
leaderboardFilter === 'all'
|
||||||
? games
|
? seasonFilteredGames
|
||||||
: games.filter(
|
: seasonFilteredGames.filter(
|
||||||
(game) =>
|
(game) =>
|
||||||
game.gameType.toLowerCase() === leaderboardFilter?.toLowerCase()
|
game.gameType.toLowerCase() === leaderboardFilter?.toLowerCase()
|
||||||
)
|
)
|
||||||
@@ -115,11 +154,11 @@ function UserInfoComponent() {
|
|||||||
)
|
)
|
||||||
: filteredGamesByLeaderboard.filter((game) => game.result === 'tie')
|
: filteredGamesByLeaderboard.filter((game) => game.result === 'tie')
|
||||||
|
|
||||||
const games_played = games.length
|
const games_played = seasonFilteredGames.length
|
||||||
let wins = 0
|
let wins = 0
|
||||||
let losses = 0
|
let losses = 0
|
||||||
let ties = 0
|
let ties = 0
|
||||||
for (const game of games) {
|
for (const game of seasonFilteredGames) {
|
||||||
if (game.result === 'win') {
|
if (game.result === 'win') {
|
||||||
wins++
|
wins++
|
||||||
} else if (game.result === 'loss') {
|
} else if (game.result === 'loss') {
|
||||||
@@ -131,8 +170,8 @@ function UserInfoComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const aliases = [...new Set(games.map((g) => g.playerName))]
|
const aliases = [...new Set(seasonFilteredGames.map((g) => g.playerName))]
|
||||||
const lastGame = games.at(0)
|
const lastGame = seasonFilteredGames.at(0)
|
||||||
|
|
||||||
const currentName = lastGame?.playerName ?? discord_user.username
|
const currentName = lastGame?.playerName ?? discord_user.username
|
||||||
const meaningful_games = games_played - ties
|
const meaningful_games = games_played - ties
|
||||||
@@ -150,24 +189,27 @@ function UserInfoComponent() {
|
|||||||
meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0,
|
meaningful_games > 0 ? Math.floor((losses / meaningful_games) * 100) : 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstGame = games.at(-1)
|
const firstGame = seasonFilteredGames.at(-1)
|
||||||
|
|
||||||
// Get last games for each leaderboard
|
// Get last games for each leaderboard
|
||||||
const lastRankedGame = games
|
const lastRankedGame = seasonFilteredGames
|
||||||
.filter((game) => game.gameType === 'ranked')
|
.filter((game) => game.gameType === 'ranked')
|
||||||
.at(0)
|
.at(0)
|
||||||
const lastVanillaGame = games
|
const lastVanillaGame = seasonFilteredGames
|
||||||
.filter((game) => game.gameType.toLowerCase() === 'vanilla')
|
.filter((game) => game.gameType.toLowerCase() === 'vanilla')
|
||||||
.at(0)
|
.at(0)
|
||||||
|
|
||||||
|
// Calculate average opponent MMR for meaningful games
|
||||||
|
const rankedMeaningfulGames = seasonFilteredGames.filter(
|
||||||
|
(g) =>
|
||||||
|
g.result !== 'tie' && g.result !== 'unknown' && g.gameType === 'ranked'
|
||||||
|
)
|
||||||
|
|
||||||
const avgOpponentMmr =
|
const avgOpponentMmr =
|
||||||
games
|
rankedMeaningfulGames.length > 0
|
||||||
.filter(
|
? rankedMeaningfulGames.reduce((acc, g) => acc + g.opponentMmr, 0) /
|
||||||
(g) =>
|
rankedMeaningfulGames.length
|
||||||
g.result !== 'tie' &&
|
: 0
|
||||||
g.result !== 'unknown' &&
|
|
||||||
g.gameType === 'ranked'
|
|
||||||
)
|
|
||||||
.reduce((acc, g) => acc + g.opponentMmr, 0) / meaningful_games
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||||
<div className='mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-1 flex-col'>
|
<div className='mx-auto flex w-[calc(100%-1rem)] max-w-fd-container flex-1 flex-col'>
|
||||||
@@ -236,6 +278,25 @@ function UserInfoComponent() {
|
|||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Show historic rank data if available */}
|
||||||
|
{historicRankedData && season !== 'all' && (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300'
|
||||||
|
>
|
||||||
|
<Calendar className='mr-1 h-3 w-3' />
|
||||||
|
<span>
|
||||||
|
{season === 'season3' ? 'Season 2' : 'Season 3'} Rank:{' '}
|
||||||
|
{isNonNullish(historicRankedData.rank)
|
||||||
|
? `#${historicRankedData.rank}`
|
||||||
|
: 'N/A'}
|
||||||
|
{isNonNullish(historicRankedData.mmr)
|
||||||
|
? ` (${Math.round(historicRankedData.mmr)} MMR)`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{!!vanillaLeaderboard && (
|
{!!vanillaLeaderboard && (
|
||||||
<Badge
|
<Badge
|
||||||
variant='outline'
|
variant='outline'
|
||||||
@@ -413,7 +474,7 @@ function UserInfoComponent() {
|
|||||||
<TabsTrigger value='achievements'>Achievements</TabsTrigger>
|
<TabsTrigger value='achievements'>Achievements</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<div className='mr-2 flex items-center gap-2'>
|
<div className='mr-2 flex items-center gap-2'>
|
||||||
<Trophy className='h-4 w-4 text-gray-400 dark:text-zinc-400' />
|
<Trophy className='h-4 w-4 text-gray-400 dark:text-zinc-400' />
|
||||||
<Select
|
<Select
|
||||||
@@ -431,6 +492,29 @@ function UserInfoComponent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='mr-2 flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-gray-400 dark:text-zinc-400' />
|
||||||
|
<Select
|
||||||
|
value={season}
|
||||||
|
onValueChange={(value) => setSeason(value as Season)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className='h-9 w-[180px]'>
|
||||||
|
<SelectValue placeholder='Season' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='season3'>
|
||||||
|
{getSeasonDisplayName('season3')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='season2'>
|
||||||
|
{getSeasonDisplayName('season2')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='all'>
|
||||||
|
{getSeasonDisplayName('all')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Filter className='h-4 w-4 text-gray-400 dark:text-zinc-400' />
|
<Filter className='h-4 w-4 text-gray-400 dark:text-zinc-400' />
|
||||||
<Select value={filter} onValueChange={setFilter}>
|
<Select value={filter} onValueChange={setFilter}>
|
||||||
<SelectTrigger className='h-9 w-[120px]'>
|
<SelectTrigger className='h-9 w-[120px]'>
|
||||||
@@ -466,14 +550,14 @@ function UserInfoComponent() {
|
|||||||
<TabsContent value='mmr-trends' className='m-0'>
|
<TabsContent value='mmr-trends' className='m-0'>
|
||||||
<div className='overflow-hidden rounded-lg border'>
|
<div className='overflow-hidden rounded-lg border'>
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
<MmrTrendChart games={games} />
|
<MmrTrendChart games={games} season={season} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='winrate-trends' className='m-0'>
|
<TabsContent value='winrate-trends' className='m-0'>
|
||||||
<div className='overflow-hidden rounded-lg border'>
|
<div className='overflow-hidden rounded-lg border'>
|
||||||
<div className='overflow-x-auto'>
|
<div className='overflow-x-auto'>
|
||||||
<WinrateTrendChart games={games} />
|
<WinrateTrendChart games={games} season={season} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/mobile-tooltip'
|
} from '@/components/ui/mobile-tooltip'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -34,6 +41,11 @@ import {
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
|
import { RANKED_CHANNEL, VANILLA_CHANNEL } from '@/shared/constants'
|
||||||
|
import {
|
||||||
|
type Season,
|
||||||
|
SeasonSchema,
|
||||||
|
getSeasonDisplayName,
|
||||||
|
} from '@/shared/seasons'
|
||||||
import { api } from '@/trpc/react'
|
import { api } from '@/trpc/react'
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import {
|
import {
|
||||||
@@ -41,11 +53,8 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Flame,
|
Flame,
|
||||||
Medal,
|
|
||||||
Search,
|
Search,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Trophy,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
@@ -124,22 +133,45 @@ export function LeaderboardPage() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
// Get the leaderboard type from URL or default to 'ranked'
|
// Get the leaderboard type from URL or default to 'ranked'
|
||||||
const leaderboardType = searchParams.get('type') || 'ranked'
|
const leaderboardType = searchParams.get('type') || 'ranked'
|
||||||
|
// Get the season from URL or default to 'season3'
|
||||||
|
const seasonParam = searchParams.get('season') as Season | null
|
||||||
|
const season =
|
||||||
|
seasonParam && SeasonSchema.safeParse(seasonParam).success
|
||||||
|
? seasonParam
|
||||||
|
: 'season3'
|
||||||
const [gamesAmount, setGamesAmount] = useState([0, 100])
|
const [gamesAmount, setGamesAmount] = useState([0, 100])
|
||||||
|
|
||||||
// State for search and sorting
|
// State for search and sorting
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [sortColumn, setSortColumn] = useState('rank')
|
const [sortColumn, setSortColumn] = useState(
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
season === 'season2' ? 'mmr' : 'rank'
|
||||||
|
)
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(
|
||||||
|
season === 'season2' ? 'desc' : 'asc'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update sort settings when season changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (season === 'season2') {
|
||||||
|
setSortColumn('mmr')
|
||||||
|
setSortDirection('desc')
|
||||||
|
} else {
|
||||||
|
setSortColumn('rank')
|
||||||
|
setSortDirection('asc')
|
||||||
|
}
|
||||||
|
}, [season])
|
||||||
|
|
||||||
// Fetch leaderboard data
|
// Fetch leaderboard data
|
||||||
const [rankedLeaderboardResult] =
|
const [rankedLeaderboardResult] =
|
||||||
api.leaderboard.get_leaderboard.useSuspenseQuery({
|
api.leaderboard.get_leaderboard.useSuspenseQuery({
|
||||||
channel_id: RANKED_CHANNEL,
|
channel_id: RANKED_CHANNEL,
|
||||||
|
season,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [vanillaLeaderboardResult] =
|
const [vanillaLeaderboardResult] =
|
||||||
api.leaderboard.get_leaderboard.useSuspenseQuery({
|
api.leaderboard.get_leaderboard.useSuspenseQuery({
|
||||||
channel_id: VANILLA_CHANNEL,
|
channel_id: VANILLA_CHANNEL,
|
||||||
|
season,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get the current leaderboard based on selected tab
|
// Get the current leaderboard based on selected tab
|
||||||
@@ -179,6 +211,13 @@ export function LeaderboardPage() {
|
|||||||
router.push(`?${params.toString()}`)
|
router.push(`?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle season change
|
||||||
|
const handleSeasonChange = (value: Season) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
params.set('season', value)
|
||||||
|
router.push(`?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
const [sliderValue, setSliderValue] = useState([0, maxGamesAmount])
|
const [sliderValue, setSliderValue] = useState([0, maxGamesAmount])
|
||||||
const handleGamesAmountSliderChange = (value: number[]) => {
|
const handleGamesAmountSliderChange = (value: number[]) => {
|
||||||
setSliderValue(value)
|
setSliderValue(value)
|
||||||
@@ -261,10 +300,36 @@ export function LeaderboardPage() {
|
|||||||
className='flex flex-1 flex-col px-0 py-4 md:py-6'
|
className='flex flex-1 flex-col px-0 py-4 md:py-6'
|
||||||
>
|
>
|
||||||
<div className='mb-6 flex w-full flex-col items-start justify-between gap-4 md:items-center lg:flex-row'>
|
<div className='mb-6 flex w-full flex-col items-start justify-between gap-4 md:items-center lg:flex-row'>
|
||||||
<TabsList className='border border-gray-200 border-b bg-gray-50 dark:border-zinc-800 dark:bg-zinc-800/50'>
|
<div className='flex flex-col gap-4 md:flex-row md:items-center'>
|
||||||
<TabsTrigger value='ranked'>Ranked Leaderboard</TabsTrigger>
|
<TabsList className='border border-gray-200 border-b bg-gray-50 dark:border-zinc-800 dark:bg-zinc-800/50'>
|
||||||
<TabsTrigger value='vanilla'>Vanilla Leaderboard</TabsTrigger>
|
<TabsTrigger value='ranked'>Ranked Leaderboard</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value='vanilla'>Vanilla Leaderboard</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Label htmlFor='season-select' className='text-sm'>
|
||||||
|
Season:
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={season}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleSeasonChange(value as Season)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='season-select' className='w-[180px]'>
|
||||||
|
<SelectValue placeholder='Select season' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='season3'>
|
||||||
|
{getSeasonDisplayName('season3')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='season2'>
|
||||||
|
{getSeasonDisplayName('season2')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex w-full flex-col items-center justify-end gap-2 lg:w-fit lg:flex-row lg:gap-4'
|
'flex w-full flex-col items-center justify-end gap-2 lg:w-fit lg:flex-row lg:gap-4'
|
||||||
|
|||||||
57872
src/data/leaderboard-snapshot-eos2.json
Normal file
57872
src/data/leaderboard-snapshot-eos2.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@/server/api/trpc'
|
} from '@/server/api/trpc'
|
||||||
import { LeaderboardService } from '@/server/services/leaderboard'
|
import { LeaderboardService } from '@/server/services/leaderboard'
|
||||||
import type { LeaderboardEntry } from '@/server/services/neatqueue.service'
|
import type { LeaderboardEntry } from '@/server/services/neatqueue.service'
|
||||||
|
import { SeasonSchema } from '@/shared/seasons'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
const service = new LeaderboardService()
|
const service = new LeaderboardService()
|
||||||
|
|
||||||
@@ -13,13 +14,24 @@ export const leaderboard_router = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
channel_id: z.string(),
|
channel_id: z.string(),
|
||||||
|
season: SeasonSchema.optional().default('season3'),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const result = await service.getLeaderboard(input.channel_id)
|
if (input.season === 'season2') {
|
||||||
return {
|
// For Season 2, use the snapshot data
|
||||||
data: result.data as LeaderboardEntry[],
|
const season2Data = await service.getSeason2Leaderboard(input.channel_id)
|
||||||
isStale: result.isStale,
|
return {
|
||||||
|
data: season2Data,
|
||||||
|
isStale: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For Season 3 or all, use the current data
|
||||||
|
const result = await service.getLeaderboard(input.channel_id)
|
||||||
|
return {
|
||||||
|
data: result.data as LeaderboardEntry[],
|
||||||
|
isStale: result.isStale,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
get_leaderboard_snapshots: adminProcedure
|
get_leaderboard_snapshots: adminProcedure
|
||||||
@@ -40,14 +52,26 @@ export const leaderboard_router = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
channel_id: z.string(),
|
channel_id: z.string(),
|
||||||
user_id: z.string(),
|
user_id: z.string(),
|
||||||
|
season: SeasonSchema.optional().default('season3'),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const result = await service.getUserRank(input.channel_id, input.user_id)
|
if (input.season === 'season2') {
|
||||||
if (!result) return null
|
// For Season 2, use the snapshot data
|
||||||
return {
|
const userData = await service.getSeason2UserRank(input.channel_id, input.user_id)
|
||||||
data: result.data,
|
if (!userData) return null
|
||||||
isStale: result.isStale,
|
return {
|
||||||
|
data: userData,
|
||||||
|
isStale: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For Season 3 or all, use the current data
|
||||||
|
const result = await service.getUserRank(input.channel_id, input.user_id)
|
||||||
|
if (!result) return null
|
||||||
|
return {
|
||||||
|
data: result.data,
|
||||||
|
isStale: result.isStale,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { db } from '@/server/db'
|
|||||||
import { leaderboardSnapshots, metadata } from '@/server/db/schema'
|
import { leaderboardSnapshots, metadata } from '@/server/db/schema'
|
||||||
import { eq, desc } from 'drizzle-orm'
|
import { eq, desc } from 'drizzle-orm'
|
||||||
import { sql } from 'drizzle-orm'
|
import { sql } from 'drizzle-orm'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export type LeaderboardResponse = {
|
export type LeaderboardResponse = {
|
||||||
data: LeaderboardEntry[]
|
data: LeaderboardEntry[]
|
||||||
@@ -22,10 +24,76 @@ export type UserRankResponse = {
|
|||||||
} | null
|
} | null
|
||||||
|
|
||||||
export class LeaderboardService {
|
export class LeaderboardService {
|
||||||
|
private season2DataCache: Map<string, LeaderboardEntry[]> = new Map()
|
||||||
|
|
||||||
private getZSetKey(channel_id: string) {
|
private getZSetKey(channel_id: string) {
|
||||||
return `zset:leaderboard:${channel_id}`
|
return `zset:leaderboard:${channel_id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Season 2 data from the snapshot file
|
||||||
|
private loadSeason2Data(channel_id: string): LeaderboardEntry[] {
|
||||||
|
// Check if data is already cached
|
||||||
|
if (this.season2DataCache.has(channel_id)) {
|
||||||
|
return this.season2DataCache.get(channel_id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Path to the Season 2 snapshot file
|
||||||
|
const filePath = path.join(process.cwd(), 'src', 'data', 'leaderboard-snapshot-eos2.json')
|
||||||
|
|
||||||
|
// Read and parse the file
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const data = JSON.parse(fileContent)
|
||||||
|
|
||||||
|
// Extract and format the leaderboard entries
|
||||||
|
const entries = data.alltime.map((entry: any) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
mmr: entry.data.mmr,
|
||||||
|
wins: entry.data.wins,
|
||||||
|
losses: entry.data.losses,
|
||||||
|
streak: entry.data.streak,
|
||||||
|
totalgames: entry.data.totalgames,
|
||||||
|
peak_mmr: entry.data.peak_mmr,
|
||||||
|
peak_streak: entry.data.peak_streak,
|
||||||
|
rank: entry.data.rank,
|
||||||
|
winrate: entry.data.winrate,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Cache the data for future requests
|
||||||
|
this.season2DataCache.set(channel_id, entries)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading Season 2 data:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Season 2 leaderboard data
|
||||||
|
async getSeason2Leaderboard(channel_id: string): Promise<LeaderboardEntry[]> {
|
||||||
|
const entries = this.loadSeason2Data(channel_id)
|
||||||
|
|
||||||
|
// Sort entries by MMR in descending order
|
||||||
|
const sortedEntries = [...entries].sort((a, b) => b.mmr - a.mmr)
|
||||||
|
|
||||||
|
// Recalculate ranks based on sorted order
|
||||||
|
return sortedEntries.map((entry, idx) => ({
|
||||||
|
...entry,
|
||||||
|
rank: idx + 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Season 2 user rank data
|
||||||
|
async getSeason2UserRank(channel_id: string, user_id: string): Promise<LeaderboardEntry | null> {
|
||||||
|
// Get the sorted leaderboard with recalculated ranks
|
||||||
|
const sortedLeaderboard = await this.getSeason2Leaderboard(channel_id)
|
||||||
|
|
||||||
|
// Find the user entry in the sorted leaderboard
|
||||||
|
const userEntry = sortedLeaderboard.find(entry => entry.id === user_id)
|
||||||
|
return userEntry || null
|
||||||
|
}
|
||||||
|
|
||||||
private getRawKey(channel_id: string) {
|
private getRawKey(channel_id: string) {
|
||||||
return `raw:leaderboard:${channel_id}`
|
return `raw:leaderboard:${channel_id}`
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/shared/seasons.ts
Normal file
35
src/shared/seasons.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const SEASON_3_START_DATE = new Date('2025-06-02T13:00:00.000Z')
|
||||||
|
|
||||||
|
// Season type for selection
|
||||||
|
export const SeasonSchema = z.enum(['season2', 'season3', 'all'])
|
||||||
|
export type Season = z.infer<typeof SeasonSchema>
|
||||||
|
|
||||||
|
// Helper function to determine which season a date belongs to
|
||||||
|
export function getSeasonForDate(date: Date): 'season2' | 'season3' {
|
||||||
|
return date < SEASON_3_START_DATE ? 'season2' : 'season3'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to filter games by season
|
||||||
|
export function filterGamesBySeason(games: any[], season: Season): any[] {
|
||||||
|
if (season === 'all') return games
|
||||||
|
|
||||||
|
return games.filter((game) => {
|
||||||
|
const gameDate = new Date(game.gameTime)
|
||||||
|
const gameSeason = getSeasonForDate(gameDate)
|
||||||
|
return gameSeason === season
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get a display name for a season
|
||||||
|
export function getSeasonDisplayName(season: Season): string {
|
||||||
|
switch (season) {
|
||||||
|
case 'season2':
|
||||||
|
return 'Season 2'
|
||||||
|
case 'season3':
|
||||||
|
return 'Season 3 (Current)'
|
||||||
|
case 'all':
|
||||||
|
return 'All Seasons'
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user