rewrite: Profile, Stats, Leaderboard.
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
from enum import IntEnum
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
from discord.ui.button import ButtonStyle, button, Button
|
||||
from discord.ui.select import select, Select, SelectOption
|
||||
|
||||
from gui.base import CardMode
|
||||
|
||||
from meta import LionBot, conf
|
||||
from utils.lib import MessageArgs
|
||||
from utils.ui import input
|
||||
from core.lion_guild import VoiceMode
|
||||
from babel.translator import ctx_translator, LazyStr
|
||||
|
||||
from ..data import StatsData
|
||||
from ..graphics.leaderboard import get_leaderboard_card
|
||||
from .. import babel
|
||||
|
||||
from .base import StatsUI
|
||||
|
||||
|
||||
class LBPeriod(IntEnum):
|
||||
SEASON = 0
|
||||
DAY = 1
|
||||
WEEK = 2
|
||||
MONTH = 3
|
||||
ALLTIME = 4
|
||||
|
||||
|
||||
class StatType(IntEnum):
|
||||
VOICE = 0
|
||||
TEXT = 1
|
||||
ANKI = 2
|
||||
|
||||
|
||||
class LeaderboardUI(StatsUI):
|
||||
page_size = 10
|
||||
|
||||
def __init__(self, bot, user, guild, **kwargs):
|
||||
super().__init__(bot, user, guild, **kwargs)
|
||||
self.data: StatsData = bot.get_cog('StatsCog').data
|
||||
|
||||
# ----- Constants initialised on run -----
|
||||
self.show_season = None
|
||||
self.period_starts = None
|
||||
|
||||
# ----- UI state -----
|
||||
# Whether the leaderboard is focused on the calling member
|
||||
self.focused = True
|
||||
|
||||
# Current visible page number
|
||||
self.pagen = 0
|
||||
|
||||
# Current stat type
|
||||
self.stat_type = StatType.VOICE
|
||||
|
||||
# Start of the current period
|
||||
self.current_period = LBPeriod.SEASON
|
||||
|
||||
# Current rendered leaderboard card, if it exists
|
||||
self.card = None
|
||||
|
||||
# ----- Cached and on-demand data -----
|
||||
# Cache of the full leaderboards for each type and period, populated on demand
|
||||
# (type, period) -> List[(userid, duration)]
|
||||
self.lb_data = {}
|
||||
|
||||
# Cache of the cards already displayed
|
||||
# (type, period) -> (pagen -> Optional[Future[Card]])
|
||||
self.cache = {}
|
||||
|
||||
async def run(self, interaction: discord.Interaction):
|
||||
self._original = interaction
|
||||
|
||||
# Fetch guild data and populate period starts
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
periods = {}
|
||||
self.show_season = bool(lguild.data.season_start)
|
||||
if self.show_season:
|
||||
periods[LBPeriod.SEASON] = lguild.data.season_start
|
||||
self.current_period = LBPeriod.SEASON
|
||||
else:
|
||||
self.current_period = LBPeriod.ALLTIME
|
||||
periods[LBPeriod.DAY] = lguild.today
|
||||
periods[LBPeriod.WEEK] = lguild.week_start
|
||||
periods[LBPeriod.MONTH] = lguild.month_start
|
||||
self.period_starts = periods
|
||||
|
||||
self.focused = True
|
||||
await self.refresh()
|
||||
|
||||
async def focus_caller(self):
|
||||
"""
|
||||
Focus the calling user, if possible.
|
||||
"""
|
||||
self.focused = True
|
||||
data = await self.current_data()
|
||||
if data:
|
||||
caller_index = next((i for i, (uid, _) in enumerate(data) if uid == self.userid), None)
|
||||
if caller_index is not None:
|
||||
self.pagen = caller_index // self.page_size
|
||||
|
||||
async def _fetch_lb_data(self, stat_type, period) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Worker for `fetch_lb_data`.
|
||||
"""
|
||||
if stat_type is StatType.VOICE:
|
||||
if period is LBPeriod.ALLTIME:
|
||||
data = await self.data.VoiceSessionStats.leaderboard_all(self.guildid)
|
||||
elif (period_start := self.period_starts.get(period, None)) is None:
|
||||
raise ValueError("Uninitialised period requested!")
|
||||
else:
|
||||
data = await self.data.VoiceSessionStats.leaderboard_since(
|
||||
self.guildid, period_start
|
||||
)
|
||||
elif stat_type is StatType.TEXT:
|
||||
if period is LBPeriod.ALLTIME:
|
||||
data = await self.data.MemberExp.leaderboard_all(self.guildid)
|
||||
elif (period_start := self.period_starts.get(period, None)) is None:
|
||||
raise ValueError("Uninitialised period requested!")
|
||||
else:
|
||||
data = await self.data.MemberExp.leaderboard_since(
|
||||
self.guildid, period_start
|
||||
)
|
||||
# TODO: Handle removing members in invisible roles
|
||||
return data
|
||||
|
||||
async def fetch_lb_data(self, stat_type, period):
|
||||
"""
|
||||
Fetch the leaderboard data for the given type and period.
|
||||
|
||||
Uses cached futures so that requests are not repeated.
|
||||
"""
|
||||
key = (stat_type, period)
|
||||
future = self.lb_data.get(key, None)
|
||||
if future is not None and not future.cancelled():
|
||||
result = await future
|
||||
else:
|
||||
future = asyncio.create_task(self._fetch_lb_data(*key))
|
||||
self.lb_data[key] = future
|
||||
result = await future
|
||||
return result
|
||||
|
||||
async def current_data(self):
|
||||
"""
|
||||
Helper method to retrieve the leaderboard data for the current mode.
|
||||
"""
|
||||
return await self.fetch_lb_data(self.stat_type, self.current_period)
|
||||
|
||||
async def _render_card(self, stat_type, period, pagen, data):
|
||||
"""
|
||||
Render worker for the given leaderboard page.
|
||||
"""
|
||||
if data:
|
||||
# Calculate page data
|
||||
page_starts_at = pagen * self.page_size
|
||||
userids, times = zip(*data[page_starts_at:page_starts_at + self.page_size])
|
||||
positions = range(page_starts_at + 1, page_starts_at + self.page_size + 1)
|
||||
|
||||
page_data = zip(userids, positions, times)
|
||||
if self.stat_type is StatType.VOICE:
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
if lguild.guild_mode.voice is VoiceMode.VOICE:
|
||||
mode = CardMode.VOICE
|
||||
else:
|
||||
mode = CardMode.STUDY
|
||||
elif self.stat_type is StatType.TEXT:
|
||||
mode = CardMode.TEXT
|
||||
elif self.stat_type is StatType.ANKI:
|
||||
mode = CardMode.ANKI
|
||||
card = await get_leaderboard_card(
|
||||
self.bot, self.userid, self.guildid,
|
||||
mode,
|
||||
list(page_data)
|
||||
)
|
||||
await card.render()
|
||||
return card
|
||||
else:
|
||||
# Leaderboard is empty
|
||||
return None
|
||||
|
||||
async def fetch_page(self, stat_type, period, pagen):
|
||||
"""
|
||||
Fetch the requested leaderboard page as a rendered LeaderboardCard.
|
||||
|
||||
Applies cache where possible.
|
||||
"""
|
||||
lb_data = await self.fetch_lb_data(stat_type, period)
|
||||
if lb_data:
|
||||
pagen %= (len(lb_data) // self.page_size) + 1
|
||||
else:
|
||||
pagen = 0
|
||||
key = (stat_type, period, pagen)
|
||||
if (future := self.cache.get(key, None)) is not None and not future.cancelled():
|
||||
card = await future
|
||||
else:
|
||||
future = asyncio.create_task(self._render_card(
|
||||
stat_type,
|
||||
period,
|
||||
pagen,
|
||||
lb_data
|
||||
))
|
||||
self.cache[key] = future
|
||||
card = await future
|
||||
return card
|
||||
|
||||
# UI interface
|
||||
@select(placeholder="Select Activity Type")
|
||||
async def stat_menu(self, selection: discord.Interaction, selected):
|
||||
if selected.values:
|
||||
await selection.response.defer(thinking=True)
|
||||
self.stat_type = StatType(int(selected.values[0]))
|
||||
self.focused = True
|
||||
await self.refresh(thinking=selection)
|
||||
|
||||
async def stat_menu_refresh(self):
|
||||
# TODO: Customise based on configuration
|
||||
menu = self.stat_menu
|
||||
options = []
|
||||
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
|
||||
if lguild.guild_mode.voice is VoiceMode.VOICE:
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Voice Activity",
|
||||
value=str(StatType.VOICE.value)
|
||||
)
|
||||
)
|
||||
else:
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Study Statistics",
|
||||
value=str(StatType.VOICE.value)
|
||||
)
|
||||
)
|
||||
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Message Activity",
|
||||
value=str(StatType.TEXT.value)
|
||||
)
|
||||
)
|
||||
options.append(
|
||||
SelectOption(
|
||||
label="Anki Cards Reviewed",
|
||||
value=str(StatType.ANKI.value)
|
||||
)
|
||||
)
|
||||
menu.options = options
|
||||
|
||||
@button(label="This Season", style=ButtonStyle.grey)
|
||||
async def season_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.SEASON
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="Today", style=ButtonStyle.grey)
|
||||
async def day_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.DAY
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Week", style=ButtonStyle.grey)
|
||||
async def week_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.WEEK
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="This Month", style=ButtonStyle.grey)
|
||||
async def month_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.MONTH
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="All Time", style=ButtonStyle.grey)
|
||||
async def alltime_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.current_period = LBPeriod.ALLTIME
|
||||
self.focused = True
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
|
||||
async def prev_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.pagen -= 1
|
||||
self.focused = False
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
@button(label="Jump", style=ButtonStyle.blurple)
|
||||
async def jump_button(self, press: discord.Interaction, pressed: Button):
|
||||
"""
|
||||
Jump-to-page button.
|
||||
Loads a page-switch dialogue.
|
||||
"""
|
||||
try:
|
||||
interaction, value = await input(
|
||||
press,
|
||||
title="Jump to page",
|
||||
question="Page number to jump to"
|
||||
)
|
||||
value = value.strip()
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
if not value.lstrip('- ').isdigit():
|
||||
error_embed = discord.Embed(
|
||||
title="Invalid page number, please try again!",
|
||||
colour=discord.Colour.brand_red()
|
||||
)
|
||||
await interaction.response.send_message(embed=error_embed, ephemeral=True)
|
||||
else:
|
||||
await interaction.response.defer(thinking=True)
|
||||
pagen = int(value.lstrip('- '))
|
||||
if value.startswith('-'):
|
||||
pagen = -1 * pagen
|
||||
elif pagen > 0:
|
||||
pagen = pagen - 1
|
||||
self.pagen = pagen
|
||||
self.focused = False
|
||||
await self.refresh(thinking=interaction)
|
||||
|
||||
async def jump_button_refresh(self):
|
||||
component = self.jump_button
|
||||
|
||||
data = await self.current_data()
|
||||
if not data:
|
||||
# Component should be hidden
|
||||
component.label = "-/-"
|
||||
component.disabled = True
|
||||
else:
|
||||
page_count = (len(data) // self.page_size) + 1
|
||||
pagen = self.pagen % page_count
|
||||
component.label = "{}/{}".format(pagen + 1, page_count)
|
||||
component.disabled = (page_count <= 1)
|
||||
|
||||
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
|
||||
async def next_button(self, press: discord.Interaction, pressed: Button):
|
||||
await press.response.defer(thinking=True)
|
||||
self.pagen += 1
|
||||
self.focused = False
|
||||
await self.refresh(thinking=press)
|
||||
|
||||
async def make_message(self) -> MessageArgs:
|
||||
"""
|
||||
Generate UI message arguments from stored data
|
||||
"""
|
||||
if self.card is not None:
|
||||
args = MessageArgs(
|
||||
embed=None,
|
||||
file=self.card.as_file('leaderboard.png')
|
||||
)
|
||||
else:
|
||||
# TOLOCALISE:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Empty Leaderboard!",
|
||||
description=(
|
||||
"There has been no activity of this type in this period!"
|
||||
)
|
||||
)
|
||||
args = MessageArgs(embed=embed, files=[])
|
||||
return args
|
||||
|
||||
async def refresh_components(self):
|
||||
await asyncio.gather(
|
||||
self.jump_button_refresh(),
|
||||
self.close_button_refresh(),
|
||||
self.stat_menu_refresh()
|
||||
)
|
||||
|
||||
# Compute period row
|
||||
period_buttons = {
|
||||
LBPeriod.DAY: self.day_button,
|
||||
LBPeriod.WEEK: self.week_button,
|
||||
LBPeriod.MONTH: self.month_button
|
||||
}
|
||||
if self.show_season:
|
||||
period_buttons[LBPeriod.SEASON] = self.season_button
|
||||
else:
|
||||
period_buttons[LBPeriod.ALLTIME] = self.alltime_button
|
||||
|
||||
for period, component in period_buttons.items():
|
||||
if period is self.current_period:
|
||||
component.style = ButtonStyle.blurple
|
||||
else:
|
||||
component.style = ButtonStyle.grey
|
||||
|
||||
period_row = tuple(period_buttons.values())
|
||||
|
||||
# Compute page row
|
||||
data = await self.current_data()
|
||||
multipage = len(data) > self.page_size
|
||||
if multipage:
|
||||
page_row = (
|
||||
self.prev_button, self.jump_button, self.close_button, self.next_button
|
||||
)
|
||||
else:
|
||||
period_row = (*period_row, self.close_button)
|
||||
page_row = ()
|
||||
|
||||
self._layout = [
|
||||
(self.stat_menu,),
|
||||
period_row,
|
||||
page_row
|
||||
]
|
||||
|
||||
async def reload(self):
|
||||
"""
|
||||
Reload UI data, applying cache where possible.
|
||||
"""
|
||||
if self.focused:
|
||||
await self.focus_caller()
|
||||
self.card = await self.fetch_page(
|
||||
self.stat_type,
|
||||
self.current_period,
|
||||
self.pagen
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user