From 7f79009ac797bf9d25fd95b4a20ce2ee98a7fe33 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sun, 14 May 2023 12:31:43 +0300 Subject: [PATCH] rewrite: Profile, Stats, Leaderboard. --- src/modules/statistics/cog.py | 75 +++- src/modules/statistics/config.py | 0 src/modules/statistics/data.py | 362 ++++++++++++++- src/modules/statistics/graphics/goals.py | 21 +- .../statistics/graphics/leaderboard.py | 74 +++ src/modules/statistics/graphics/monthly.py | 31 +- src/modules/statistics/graphics/profile.py | 91 ++++ src/modules/statistics/graphics/stats.py | 36 +- src/modules/statistics/graphics/weekly.py | 26 +- src/modules/statistics/settings.py | 275 +++++++++++- src/modules/statistics/ui/__init__.py | 1 + src/modules/statistics/ui/leaderboard.py | 421 ++++++++++++++++++ src/modules/statistics/ui/profile.py | 85 +++- 13 files changed, 1447 insertions(+), 51 deletions(-) create mode 100644 src/modules/statistics/config.py create mode 100644 src/modules/statistics/graphics/leaderboard.py create mode 100644 src/modules/statistics/graphics/profile.py diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index 492ac8a9..11fe46bb 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -9,11 +9,12 @@ from discord.ui.button import ButtonStyle from meta import LionBot, LionCog, LionContext from utils.lib import error_embed from utils.ui import LeoUI, AButton +from wards import low_management from . import babel from .data import StatsData -from .ui import ProfileUI, WeeklyMonthlyUI -from .settings import StatsSettings +from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI +from .settings import StatisticsSettings, StatisticsConfigUI _p = babel._p @@ -25,12 +26,17 @@ class StatsCog(LionCog): def __init__(self, bot: LionBot): self.bot = bot self.data = bot.db.load_registry(StatsData()) - self.settings = StatsSettings + self.settings = StatisticsSettings() async def cog_load(self): await self.data.init() self.bot.core.user_config.register_model_setting(self.settings.UserGlobalStats) + self.bot.core.guild_config.register_model_setting(self.settings.SeasonStart) + self.bot.core.guild_config.register_setting(self.settings.UnrankedRoles) + + configcog = self.bot.get_cog('ConfigCog') + self.crossload_group(self.configure_group, configcog.configure_group) @cmds.hybrid_command( name=_p('cmd:me', "me"), @@ -68,4 +74,67 @@ class StatsCog(LionCog): ) @appcmds.guild_only async def leaderboard_cmd(self, ctx: LionContext): + await ctx.interaction.response.defer(thinking=True) + ui = LeaderboardUI(self.bot, ctx.author, ctx.guild) + await ui.run(ctx.interaction) + + # Setting commands + @LionCog.placeholder_group + @cmds.hybrid_group('configure', with_app_command=False) + async def configure_group(self, ctx: LionContext): ... + + @configure_group.command( + name=_p('cmd:configure_statistics', "statistics"), + description=_p('cmd:configure_statistics|desc', "Statistics configuration panel") + ) + @appcmds.rename( + season_start=_p('cmd:configure_statistics|param:season_start', "season_start") + ) + @appcmds.describe( + season_start=_p( + 'cmd:configure_statistics|param:season_start|desc', + "Time from which to start counting activity for rank badges and season leadeboards." + ) + ) + @cmds.check(low_management) + async def configure_statistics_cmd(self, ctx: LionContext, + season_start: Optional[str] = None): + t = self.bot.translator.t + + # Type checking guards + if not ctx.guild: + return + if not ctx.interaction: + return + + # Retrieve settings, using cache where possible + setting_season_start = await self.settings.SeasonStart.get(ctx.guild.id) + + modified = [] + if season_start is not None: + data = await setting_season_start._parse_string(ctx.guild.id, season_start) + setting_season_start.data = data + await setting_season_start.write() + modified.append(setting_season_start) + + # Send update ack + if modified: + # TODO + description = t(_p( + 'cmd:configure_statistics|resp:success|desc', + "Activity ranks and season leaderboard will now be measured from {season_start}." + )).format( + season_start=setting_season_start.formatted + ) + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=description + ) + await ctx.reply(embed=embed) + + if ctx.channel.id not in StatisticsConfigUI._listening or not modified: + # Launch setting group UI + configui = StatisticsConfigUI(self.bot, ctx.guild.id, ctx.channel.id) + await configui.run(ctx.interaction) + await configui.wait() diff --git a/src/modules/statistics/config.py b/src/modules/statistics/config.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/statistics/data.py b/src/modules/statistics/data.py index a486c86f..a26ddeb2 100644 --- a/src/modules/statistics/data.py +++ b/src/modules/statistics/data.py @@ -1,14 +1,52 @@ from typing import Optional, Iterable +from enum import Enum from itertools import chain from psycopg import sql -from data import RowModel, Registry, Table -from data.columns import Integer, String, Timestamp, Bool +from data import RowModel, Registry, Table, RegisterEnum +from data.columns import Integer, String, Timestamp, Bool, Column from utils.lib import utc_now +class StatisticType(Enum): + """ + Schema + ------ + CREATE TYPE StatisticType AS ENUM( + 'VOICE', + 'TEXT', + 'ANKI' + ) + """ + VOICE = ('VOICE',) + TEXT = ('TEXT',) + ANKI = ('ANKI',) + + +class ExpType(Enum): + """ + Schema + ------ + CREATE TYPE ExperienceType AS ENUM( + 'VOICE_XP', + 'TEXT_XP', + 'QUEST_XP', -- Custom guild quests + 'ACHIEVEMENT_XP', -- Individual tracked achievements + 'BONUS_XP' -- Manually adjusted XP + ); + """ + VOICE_XP = 'VOICE_XP', + TEXT_XP = 'TEXT_XP', + QUEST_XP = 'QUEST_XP', + ACHIEVEMENT_XP = 'ACHIEVEMENT_XP' + BONUS_XP = 'BONUS_XP' + + class StatsData(Registry): + StatisticType = RegisterEnum(StatisticType, name='StatisticType') + ExpType = RegisterEnum(ExpType, name='ExperienceType') + class VoiceSessionStats(RowModel): """ View containing voice session statistics. @@ -21,7 +59,7 @@ class StatsData(Registry): guildid, start_time, duration, - (start_time + duration * interval '1 second') AS end_time + (timezone('UTC', start_time) + duration * interval '1 second') AS end_time FROM session_history UNION ALL SELECT @@ -115,6 +153,321 @@ class StatsData(Registry): ) return [r['stime'] or 0 for r in await cursor.fetchall()] + @classmethod + async def leaderboard_since(cls, guildid: int, since): + """ + Return the voice totals since the given time for each member in the guild. + """ + # Retrieve sum of all sessions (incl. ongoing) ending after given time + first_query = sql.SQL( + """ + SELECT + userid, + sum(duration) as total_duration + FROM voice_sessions_combined + WHERE + guildid = %s + AND + end_time > %s + GROUP BY userid + ORDER BY total_duration DESC + """ + ) + first_query_args = (guildid, since) + + # Retrieve how much we "overshoot", from sessions which intersect the given time + second_query = sql.SQL( + """ + SELECT + SUM(EXTRACT(EPOCH FROM (%s - start_time))) AS diff, + userid + FROM voice_sessions_combined + WHERE + guildid = %s + AND + start_time < %s + AND + end_time > %s + GROUP BY userid + """ + ) + second_query_args = (since, guildid, since, since) + + conn = await cls._connector.get_connection() + async with conn.transaction(): + async with conn.cursor() as cursor: + await cursor.execute(second_query, second_query_args) + overshoot_rows = await cursor.fetchall() + overshoot = {row['userid']: int(row['diff']) for row in overshoot_rows} + + async with conn.cursor() as cursor: + await cursor.execute(first_query, first_query_args) + leaderboard = [ + (row['userid'], int(row['total_duration'] - overshoot.get(row['userid'], 0))) + for row in await cursor.fetchall() + ] + leaderboard.sort(key=lambda t: t[1], reverse=True) + return leaderboard + + @classmethod + async def leaderboard_all(cls, guildid: int): + """ + Return the all-time voice totals for the given guild. + """ + query = sql.SQL( + """ + SELECT userid, sum(duration) as total_duration + FROM voice_sessions_combined + WHERE guildid = %s + GROUP BY userid + ORDER BY total_duration DESC + """ + ) + + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute(query, (guildid, )) + leaderboard = [ + (row['userid'], int(row['total_duration'])) + for row in await cursor.fetchall() + ] + return leaderboard + + class MemberExp(RowModel): + """ + Model representing a member experience update. + + Schema + ------ + CREATE TABLE member_experience( + member_expid BIGSERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'), + amount INTEGER NOT NULL, + exp_type ExperienceType NOT NULL, + transactionid INTEGER REFERENCES coin_transactions ON DELETE SET NULL, + FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE + ); + CREATE INDEX member_experience_members ON member_experience (guildid, userid, earned_at); + CREATE INDEX member_experience_guilds ON member_experience (guildid, earned_at); + """ + _tablename_ = 'member_experience' + + member_expid = Integer(primary=True) + guildid = Integer() + userid = Integer() + earned_at = Timestamp() + amount = Integer() + exp_type: Column[ExpType] = Column() + transactionid = Integer() + + @classmethod + async def xp_since(cls, guildid: int, userid: int, *starts): + query = sql.SQL( + """ + SELECT + ( + SELECT + SUM(amount) + FROM member_experience s + WHERE + s.guildid = %s + AND s.userid = %s + AND s.earned_at >= t._start + ) AS exp + FROM + (VALUES ({})) + AS + t (_start) + ORDER BY t._start + """ + ).format( + sql.SQL('), (').join( + sql.Placeholder() for _ in starts + ) + ) + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute( + query, + tuple(chain((guildid, userid), starts)) + ) + return [r['exp'] or 0 for r in await cursor.fetchall()] + + @classmethod + async def xp_between(cls, guildid: int, userid: int, *points): + blocks = zip(points, points[1:]) + query = sql.SQL( + """ + SELECT + ( + SELECT + SUM(amount) + FROM member_experience s + WHERE + s.guildid = %s + AND s.userid = %s + AND s.earned_at >= periods._start + AND s.earned_at < periods._end + ) AS period_xp + FROM + (VALUES {}) + AS + periods (_start, _end) + ORDER BY periods._start + """ + ).format( + sql.SQL(', ').join( + sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:] + ) + ) + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute( + query, + tuple(chain((guildid, userid), *blocks)) + ) + return [r['period_xp'] or 0 for r in await cursor.fetchall()] + + @classmethod + async def leaderboard_since(cls, guildid: int, since): + """ + Return the XP totals for the given guild since the given time. + """ + query = sql.SQL( + """ + SELECT userid, sum(amount) AS total_xp + FROM member_experience + WHERE guildid = %s AND earned_at >= %s + GROUP BY userid + ORDER BY total_xp DESC + """ + ) + + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute(query, (guildid, since)) + leaderboard = [ + (row['userid'], int(row['total_xp'])) + for row in await cursor.fetchall() + ] + return leaderboard + + @classmethod + async def leaderboard_all(cls, guildid: int): + """ + Return the all-time XP totals for the given guild. + """ + query = sql.SQL( + """ + SELECT userid, sum(amount) AS total_xp + FROM member_experience + WHERE guildid = %s + GROUP BY userid + ORDER BY total_xp DESC + """ + ) + + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute(query, (guildid, )) + leaderboard = [ + (row['userid'], int(row['total_xp'])) + for row in await cursor.fetchall() + ] + return leaderboard + + class UserExp(RowModel): + """ + Model representing a user experience update. + + Schema + ------ + CREATE TABLE user_experience( + user_expid BIGSERIAL PRIMARY KEY, + userid BIGINT NOT NULL, + earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'), + amount INTEGER NOT NULL, + exp_type ExperienceType NOT NULL, + FOREIGN KEY (userid) REFERENCES user_config ON DELETE CASCADE + ); + CREATE INDEX user_experience_users ON user_experience (userid, earned_at); + """ + _tablename_ = 'user_experience' + + user_expid = Integer(primary=True) + userid = Integer() + earned_at = Timestamp() + amount = Integer() + exp_type: Column[ExpType] = Column() + + @classmethod + async def xp_since(cls, userid: int, *starts): + query = sql.SQL( + """ + SELECT + ( + SELECT + SUM(amount) + FROM user_experience s + WHERE + s.userid = %s + AND s.start_time >= t._start + ) AS exp + FROM + (VALUES ({})) + AS + t (_start) + ORDER BY t._start + """ + ).format( + sql.SQL('), (').join( + sql.Placeholder() for _ in starts + ) + ) + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute( + query, + tuple(chain((userid,), starts)) + ) + return [r['exp'] or 0 for r in await cursor.fetchall()] + + @classmethod + async def xp_between(cls, userid: int, *points): + blocks = zip(points, points[1:]) + query = sql.SQL( + """ + SELECT + ( + SELECT + SUM(global_xp) + FROM user_experience s + WHERE + s.userid = %s + AND s.start_time >= periods._start + AND s.start_time < periods._end + ) AS period_xp + FROM + (VALUES {}) + AS + periods (_start, _end) + ORDER BY periods._start + """ + ).format( + sql.SQL(', ').join( + sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:] + ) + ) + conn = await cls._connector.get_connection() + async with conn.cursor() as cursor: + await cursor.execute( + query, + tuple(chain((userid,), *blocks)) + ) + return [r['period_xp'] or 0 for r in await cursor.fetchall()] + class ProfileTag(RowModel): """ Schema @@ -256,3 +609,6 @@ class StatsData(Registry): content = String() completed = Bool() _timestamp = Timestamp() + + unranked_roles = Table('unranked_roles') + visible_statistics = Table('visible_statistics') diff --git a/src/modules/statistics/graphics/goals.py b/src/modules/statistics/graphics/goals.py index e5e18276..8e852e94 100644 --- a/src/modules/statistics/graphics/goals.py +++ b/src/modules/statistics/graphics/goals.py @@ -5,6 +5,7 @@ from data import NULL from meta import LionBot from gui.cards import WeeklyGoalCard, MonthlyGoalCard from gui.base import CardMode +from tracking.text.data import TextTrackerData from ..data import StatsData from ..lib import extract_weekid, extract_monthid, apply_week_offset, apply_month_offset @@ -41,7 +42,15 @@ async def get_goals_card( key = {'guildid': guildid or 0, 'userid': userid, 'monthid': periodid} # Extract goals and tasks - goals = await goal_model.fetch(*key.values()) + # TODO: Major data model fixy fixy here + if guildid: + goals = await goal_model.fetch_or_create(*key.values()) + else: + goals = await goal_model.fetch(*key.values()) + if not goals: + from collections import defaultdict + goals = defaultdict(lambda: -1) + task_rows = await tasks_model.fetch_where(**key) tasks = [(i, row.content, bool(row.completed)) for i, row in enumerate(task_rows)] @@ -58,11 +67,17 @@ async def get_goals_card( tasks_completed = results[0]['total'] if results else 0 # Set and compute correct middle goal column - # if mode in (CardMode.VOICE, CardMode.STUDY): - if True: + if mode in (CardMode.VOICE, CardMode.STUDY): model = data.VoiceSessionStats middle_completed = (await model.study_times_between(guildid or None, userid, start, end))[0] middle_goal = goals['study_goal'] + elif mode is CardMode.TEXT: + model = TextTrackerData.TextSessions + middle_goal = goals['message_goal'] + if guildid: + middle_completed = (await model.member_messages_between(guildid, userid, start, end))[0] + else: + middle_completed = (await model.user_messages_between(userid, start, end))[0] # Compute schedule session progress # TODO diff --git a/src/modules/statistics/graphics/leaderboard.py b/src/modules/statistics/graphics/leaderboard.py new file mode 100644 index 00000000..8e3ba7f4 --- /dev/null +++ b/src/modules/statistics/graphics/leaderboard.py @@ -0,0 +1,74 @@ +from meta import LionBot + +from gui.cards import LeaderboardCard +from gui.base import CardMode + + +async def get_leaderboard_card( + bot: LionBot, highlightid: int, guildid: int, + mode: CardMode, + entry_data: list[tuple[int, int, int]], # userid, position, time +): + """ + Render a leaderboard card with given parameters. + """ + guild = bot.get_guild(guildid) + if guild is None: + raise ValueError("Attempting to build leaderboard for non-existent guild!") + + # Need to do two passes here in case we need to do a db request for the avatars or names + avatars = {} + names = {} + missing = [] + for userid, _, _ in entry_data: + hash = name = None + if guild and (member := guild.get_member(userid)): + hash = member.avatar.key + name = member.display_name + elif (user := bot.get_user(userid)): + hash = user.avatar.key + name = user.name + elif (user_data := bot.core.data.User._cache_.get((userid,))): + hash = user_data.avatar_hash + name = user_data.name + + if hash: + avatars[userid] = hash + names[userid] = name or 'Unknown' + else: + missing.append(userid) + + if missing: + # We were unable to retrieve information for some userids + # Bulk-fetch missing users from data + data = await bot.core.data.User.fetch_where(userid=missing) + for user_data in data: + avatars[user_data.userid] = user_data.avatar_hash + names[user_data.userid] = user_data.name or 'Unknown' + missing.remove(user_data.userid) + + if missing: + # Some of the users were missing from data + # This should be impossible (by FKEY constraints on sessions) + # But just in case... + for userid in missing: + avatars[userid] = None + names[userid] = str(userid) + + highlight = None + entries = [] + for userid, position, duration in entry_data: + entries.append( + (userid, position, duration, names[userid], (userid, avatars[userid])) + ) + if userid == highlightid: + highlight = position + + # Request Card + card = LeaderboardCard( + skin={'mode': mode}, + server_name=guild.name, + entries=entries, + highlight=highlight + ) + return card diff --git a/src/modules/statistics/graphics/monthly.py b/src/modules/statistics/graphics/monthly.py index af06c948..45573f78 100644 --- a/src/modules/statistics/graphics/monthly.py +++ b/src/modules/statistics/graphics/monthly.py @@ -6,6 +6,7 @@ from data import ORDER from meta import LionBot from gui.cards import MonthlyStatsCard from gui.base import CardMode +from tracking.text.data import TextTrackerData from ..data import StatsData from ..lib import apply_month_offset @@ -34,8 +35,23 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, [0]*(calendar.monthrange(month.year, month.month)[1]) for month in months ] - # TODO: Select model based on card mode - model = data.VoiceSessionStats + if mode is CardMode.VOICE: + model = data.VoiceSessionStats + req = model.study_times_between + reqkey = (guildid or None, userid,) + elif mode is CardMode.TEXT: + model = TextTrackerData.TextSessions + if guildid: + req = model.member_messages_between + reqkey = (guildid, userid,) + else: + req = model.user_messages_between + reqkey = (userid,) + else: + # TODO: ANKI + model = data.VoiceSessionStats + req = model.study_times_between + reqkey = (guildid or None, userid,) # Get first session query = model.table.select_where().order_by('start_time', ORDER.ASC).limit(1) @@ -50,10 +66,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, longest_streak = 0 else: first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0) - first_month = first_day.replace(day=1) + # first_month = first_day.replace(day=1) # Build list of day starts up to now, or end of requested month - requests = [] + requests = [first_day] end_of_req = target_end if offset else today day = first_day while day <= end_of_req: @@ -61,7 +77,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, requests.append(day) # Request times between requested days - day_stats = await model.study_times_between(guildid or None, userid, *requests) + day_stats = await req(*reqkey, *requests) # Compute current streak and longest streak current_streak = 0 @@ -79,7 +95,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, if day < months[0]: break i = offsets[(day.year, day.month)] - monthly[i][day.day - 1] = stat / 3600 + if mode in (CardMode.VOICE, CardMode.STUDY): + monthly[i][day.day - 1] = stat / 3600 + else: + monthly[i][day.day - 1] = stat # Get member profile if user: diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py new file mode 100644 index 00000000..0f7efe93 --- /dev/null +++ b/src/modules/statistics/graphics/profile.py @@ -0,0 +1,91 @@ +from typing import Optional, TYPE_CHECKING +from datetime import datetime, timedelta + +import discord + +from meta import LionBot +from gui.cards import ProfileCard + +from modules.ranks.cog import RankCog +from modules.ranks.utils import format_stat_range + +if TYPE_CHECKING: + from ..cog import StatsCog + + +async def get_profile_card(bot: LionBot, userid: int, guildid: int): + ranks: Optional[RankCog] = bot.get_cog('RankCog') + stats: Optional[StatsCog] = bot.get_cog('StatsCog') + if ranks is None or stats is None: + return + + guild = bot.get_guild(guildid) + if guild is None: + return + + lion = await bot.core.lions.fetch_member(guildid, userid) + luser = lion.luser + member = await lion.fetch_member() + + if member: + username = (member.display_name, '#' + str(member.discriminator)) + avatar = member.avatar.key + else: + username = (lion.data.display_name, "#????") + avatar = luser.data.avatar_hash + + profile_badges = await stats.data.ProfileTag.fetch_tags(guildid, userid) + + # Fetch current and next guild rank + season_rank = await ranks.get_member_rank(guildid, userid) + rank_type = lion.lguild.config.get('rank_type').value + crank = season_rank.current_rank + nrank = season_rank.next_rank + if crank: + roleid = crank.roleid + role = guild.get_role(roleid) + name = role.name if role else str(role.id) + minimum = crank.required + maximum = nrank.required if nrank else None + rangestr = format_stat_range(rank_type, minimum, maximum) + if maximum is None: + rangestr = f"≥ {rangestr}" + current_rank = (name, rangestr) + + if maximum: + rank_progress = (season_rank.stat - minimum) / (maximum - minimum) + else: + rank_progress = 1 + else: + current_rank = None + rank_progress = 0 + + if nrank: + roleid = nrank.roleid + role = guild.get_role(roleid) + name = role.name if role else str(role.id) + minimum = nrank.required + + guild_ranks = await ranks.get_guild_ranks(guildid) + nnrank = next((rank for rank in guild_ranks if rank.required > nrank.required), None) + maximum = nnrank.required if nnrank else None + rangestr = format_stat_range(rank_type, minimum, maximum) + if maximum is None: + rangestr = f"≥ {rangestr}" + next_rank = (name, rangestr) + else: + next_rank = None + + achievements = (0, 1) + + card = ProfileCard( + user=username, + avatar=(userid, avatar), + coins=lion.data.coins, gems=luser.data.gems, gifts=0, + profile_badges=profile_badges, + achievements=achievements, + current_rank=current_rank, + rank_progress=rank_progress, + next_rank=next_rank + ) + return card diff --git a/src/modules/statistics/graphics/stats.py b/src/modules/statistics/graphics/stats.py index 2ce3d355..85cc82fb 100644 --- a/src/modules/statistics/graphics/stats.py +++ b/src/modules/statistics/graphics/stats.py @@ -5,15 +5,16 @@ import discord from meta import LionBot from gui.cards import StatsCard +from gui.base import CardMode from ..data import StatsData -async def get_stats_card(bot: LionBot, userid: int, guildid: int): +async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode): data: StatsData = bot.get_cog('StatsCog').data # TODO: Workouts - # TODO: Leaderboard rankings + # TODO: Leaderboard rankings for this season or all time guildid = guildid or 0 lion = await bot.core.lions.fetch_member(guildid, userid) @@ -31,20 +32,40 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int): ) # Extract the study times for each period - study_times = await data.VoiceSessionStats.study_times_since(guildid, userid, *period_timestamps) - print("Study times", study_times) + if mode in (CardMode.STUDY, CardMode.VOICE): + model = data.VoiceSessionStats + refkey = (guildid, userid) + ref_since = model.study_times_since + ref_between = model.study_times_between + elif mode is CardMode.TEXT: + if guildid: + model = data.MemberExp + refkey = (guildid, userid) + else: + model = data.UserExp + refkey = (userid,) + ref_since = model.xp_since + ref_between = model.xp_between + else: + # TODO ANKI + model = data.VoiceSessionStats + refkey = (guildid, userid) + ref_since = model.study_times_since + ref_between = model.study_times_between + + study_times = await ref_since(*refkey, *period_timestamps) + print("Period study times: ", study_times) # Calculate streak data by requesting times per day # First calculate starting timestamps for each day days = list(range(0, today.day + 2)) day_timestamps = [month_start + timedelta(days=day - 1) for day in days] - study_times = await data.VoiceSessionStats.study_times_between(guildid, userid, *day_timestamps) - print("Study times", study_times) + study_times_month = await ref_between(*refkey, *day_timestamps) # Then extract streak tuples streaks = [] streak_start = None - for day, stime in zip(days, study_times): + for day, stime in zip(days, study_times_month): stime = stime or 0 if stime > 0 and streak_start is None: streak_start = day @@ -59,5 +80,6 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int): list(reversed(study_times)), 100, streaks, + skin={'mode': mode} ) return card diff --git a/src/modules/statistics/graphics/weekly.py b/src/modules/statistics/graphics/weekly.py index 2ada7ce1..74b8b103 100644 --- a/src/modules/statistics/graphics/weekly.py +++ b/src/modules/statistics/graphics/weekly.py @@ -5,6 +5,7 @@ from data import ORDER from meta import LionBot from gui.cards import WeeklyStatsCard from gui.base import CardMode +from tracking.text.data import TextTrackerData from ..data import StatsData @@ -20,13 +21,27 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, user = await bot.fetch_user(userid) today = lion.today week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset) - days = [week_start + timedelta(i) for i in range(-7, 7 if offset else (today.weekday() + 1))] + days = [week_start + timedelta(i) for i in range(-7, 8 if offset else (today.weekday() + 2))] # TODO: Select statistics model based on mode - model = data.VoiceSessionStats + if mode is CardMode.VOICE: + model = data.VoiceSessionStats + day_stats = await model.study_times_between(guildid or None, userid, *days) + day_stats = list(map(lambda n: n // 3600, day_stats)) + elif mode is CardMode.TEXT: + model = TextTrackerData.TextSessions + if guildid: + day_stats = await model.member_messages_between(guildid, userid, *days) + else: + day_stats = await model.user_messages_between(userid, *days) + else: + # TODO: ANKI + model = data.VoiceSessionStats + day_stats = await model.study_times_between(guildid or None, userid, *days) + day_stats = list(map(lambda n: n // 3600, day_stats)) # Get user session rows - query = model.table.select_where() + query = model.table.select_where(model.start_time >= days[0]) if guildid: query = query.where(userid=userid, guildid=guildid).order_by('start_time', ORDER.ASC) else: @@ -34,7 +49,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, sessions = await query # Extract quantities per-day - day_stats = await model.study_times_between(guildid or None, userid, *days) for i in range(14 - len(day_stats)): day_stats.append(0) @@ -49,9 +63,9 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, timezone=str(lion.timezone), now=lion.now.timestamp(), week=week_start.timestamp(), - daily=tuple(map(lambda n: n/3600, day_stats)), + daily=tuple(map(int, day_stats)), sessions=[ - (int(session['start_time'].timestamp()), int(session['end_time'].timestamp())) + (int(session['start_time'].timestamp()), int(session['start_time'].timestamp() + int(session['duration']))) for session in sessions ], skin={'mode': mode} diff --git a/src/modules/statistics/settings.py b/src/modules/statistics/settings.py index b8ac77d0..440805c2 100644 --- a/src/modules/statistics/settings.py +++ b/src/modules/statistics/settings.py @@ -1,15 +1,51 @@ -from settings import ModelData -from settings.setting_types import BoolSetting +""" +Configuration settings associated to the statistics module +""" +from typing import Optional +import asyncio +import discord +from discord.ui.select import select, Select, SelectOption, RoleSelect +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from settings import ListData, ModelData, InteractiveSetting +from settings.setting_types import RoleListSetting, EnumSetting, ListSetting, BoolSetting, TimestampSetting from settings.groups import SettingGroup +from meta import conf, LionBot +from meta.context import ctx_bot +from utils.lib import tabulate +from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI +from utils.lib import MessageArgs from core.data import CoreData +from core.lion_guild import VoiceMode +from babel.translator import ctx_translator from . import babel +from .data import StatsData, StatisticType _p = babel._p -class StatsSettings(SettingGroup): +class StatTypeSetting(EnumSetting): + """ + ABC setting type mixin describing an available stat type. + """ + _enum = StatisticType + _outputs = { + StatisticType.VOICE: '`Voice`', + StatisticType.TEXT: '`Text`', + StatisticType.ANKI: '`Anki`' + } + _inputs = { + 'voice': StatisticType.VOICE, + 'study': StatisticType.VOICE, + 'text': StatisticType.TEXT, + 'anki': StatisticType.ANKI + } + + +class StatisticsSettings(SettingGroup): class UserGlobalStats(ModelData, BoolSetting): """ User setting, describing whether to display global statistics or not in servers. @@ -20,9 +56,240 @@ class StatsSettings(SettingGroup): _display_name = _p('userset:show_global_stats', "global_stats") _desc = _p( - 'userset:show_global_stats', + 'userset:show_global_stats|desc', + "Whether displayed statistics include all your servers." + ) + _long_desc = _p( + 'userset:show_global_stats|long_desc', "Whether statistics commands display combined stats for all servers or just your current server." ) _model = CoreData.User _column = CoreData.User.show_global_stats.name + + class SeasonStart(ModelData, TimestampSetting): + """ + Start of the statistics season, + displayed on the leaderboard and used to determine activity ranks + Time is assumed to be in set guild timezone (although supports +00 syntax) + """ + setting_id = 'season_start' + + _display_name = _p('guildset:season_start', "season_start") + _desc = _p( + 'guildset:season_start|desc', + "Start of the current statistics season." + ) + _long_desc = _p( + 'guildset:season_start|long_desc', + "Activity ranks will be determined based on tracked activity since this time, " + "and the leaderboard will display activity since this time by default. " + "Unset to disable seasons and use all-time statistics instead." + ) + + _model = CoreData.Guild + _column = CoreData.Guild.season_start.name + # TODO: Offer to update badge ranks when this changes? + # TODO: Don't allow future times? + + @classmethod + async def _timezone_from_id(cls, guildid, **kwargs): + bot = ctx_bot.get() + lguild = await bot.core.lions.fetch_guild(guildid) + return lguild.timezone + + class UnrankedRoles(ListData, RoleListSetting): + """ + List of roles not displayed on the leaderboard + """ + setting_id = 'unranked_roles' + + _display_name = _p('guildset:unranked_roles', "unranked_roles") + _desc = _p( + 'guildset:unranked_roles|desc', + "Roles to exclude from the leaderboards." + ) + _long_desc = _p( + 'guildset:unranked_roles|long_desc', + "When set, members with *any* of these roles will not appear on the /leaderboard ranking list." + ) + _default = None + + _table_interface = StatsData.unranked_roles + _id_column = 'guildid' + _data_column = 'roleid' + _order_column = 'roleid' + + _cache = {} + + @property + def set_str(self): + return "Role selector below" + + class VisibleStats(ListData, ListSetting, InteractiveSetting): + """ + Which of the three stats (text, voice/study, anki) to enable in statistics views + + Default is determined by current guild mode + """ + setting_id = 'visible_stats' + + _setting = StatTypeSetting + + _display_name = _p('guildset:visible_stats', "visible_stats") + _desc = _p( + 'guildset:visible_stats|desc', + "Which statistics will be visible in the statistics commands." + ) + _long_desc = _p( + 'guildset:visible_stats|desc', + "Choose which statistics types to display in the leaderboard and statistics commands." + ) + # TODO: Format VOICE as STUDY when possible? + + _default = [ + StatisticType.VOICE, + StatisticType.TEXT, + ] + + _table_interface = StatsData.visible_statistics + _id_column = 'guildid' + _data_column = 'stat_type' + _order_column = 'stat_type' + + _cache = {} + + class DefaultStat(ModelData, StatTypeSetting): + """ + Which of the three stats to display by default + """ + setting_id = 'default_stat' + + _display_name = _p('guildset:default_stat', "default_stat") + _desc = _p( + 'guildset:default_stat|desc', + "Statistic type to display by default in setting dialogues." + ) + _long_desc = _p( + 'guildset:default_stat|long_desc', + "Which statistic type to display by default in setting dialogues." + ) + + +class StatisticsConfigUI(ConfigUI): + setting_classes = ( + StatisticsSettings.SeasonStart, + StatisticsSettings.UnrankedRoles, + StatisticsSettings.VisibleStats + ) + + def __init__(self, bot: LionBot, + guildid: int, channelid: int, **kwargs): + super().__init__(bot, guildid, channelid, **kwargs) + self.settings = self.bot.get_cog('StatsCog').settings + + @select(cls=RoleSelect, placeholder='UNRANKED_ROLE_MENU', min_values=0, max_values=25) + async def unranked_roles_menu(self, selection: discord.Interaction, selected): + """ + Selection menu for the "unranked_roles" setting. + """ + await selection.response.defer(thinking=True) + setting = self.instances[1] + setting.value = selected.values + await setting.write() + # Don't need to refresh due to instance hooks + # await self.refresh(thinking=selection) + await selection.delete_original_response() + + async def unranked_roles_menu_refresh(self): + t = self.bot.translator.t + self.unranked_roles_menu.placeholder = t(_p( + 'ui:statistics_config|menu:unranked_roles|placeholder', + "Select Unranked Roles" + )) + + @select(placeholder="STAT_TYPE_MENU", min_values=1, max_values=3) + async def stat_type_menu(self, selection: discord.Interaction, selected): + """ + Selection menu for the "visible_stats" setting. + """ + await selection.response.defer(thinking=True) + setting = self.instances[2] + data = [StatisticType((value,)) for value in selected.values] + setting.data = data + await setting.write() + await selection.delete_original_response() + + async def stat_type_menu_refresh(self): + t = self.bot.translator.t + setting = self.instances[2] + value = setting.value + + lguild = await self.bot.core.lions.fetch_guild(self.guildid) + if lguild.guild_mode.voice is VoiceMode.VOICE: + voice_label = t(_p( + 'ui:statistics_config|menu:visible_stats|item:voice|mode:voice', + "Voice Activity" + )) + else: + voice_label = t(_p( + 'ui:statistics_config|menu:visible_stats|item:voice|mode:study', + "Study Statistics" + )) + voice_option = SelectOption( + label=voice_label, + value=StatisticType.VOICE.value[0], + default=(StatisticType.VOICE in value) + ) + text_option = SelectOption( + label=t(_p( + 'ui:statistics_config|menu:visible_stats|item:text', + "Message Activity" + )), + value=StatisticType.TEXT.value[0], + default=(StatisticType.TEXT in value) + ) + anki_option = SelectOption( + label=t(_p( + 'ui:statistics_config|menu:visible_stats|item:anki', + "Anki Reviews" + )), + value=StatisticType.ANKI.value[0], + default=(StatisticType.ANKI in value) + ) + self.stat_type_menu.options = [ + voice_option, text_option, anki_option + ] + + self.stat_type_menu.placeholder = t(_p( + 'ui:statistics_config|menu:visible_stats|placeholder', + "Select Visible Statistics" + )) + + async def refresh_components(self): + await asyncio.gather( + self.edit_button_refresh(), + self.close_button_refresh(), + self.reset_button_refresh(), + self.unranked_roles_menu_refresh(), + self.stat_type_menu_refresh(), + ) + self._layout = [ + (self.unranked_roles_menu,), + (self.stat_type_menu,), + (self.edit_button, self.reset_button, self.close_button) + ] + + async def make_message(self): + t = self.bot.translator.t + title = t(_p( + 'ui:statistics_config|embed|title', + "Statistics Configuration Panel" + )) + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title + ) + for setting in self.instances: + embed.add_field(**setting.embed_field, inline=False) + return MessageArgs(embed=embed) diff --git a/src/modules/statistics/ui/__init__.py b/src/modules/statistics/ui/__init__.py index ad183c65..4d82f77d 100644 --- a/src/modules/statistics/ui/__init__.py +++ b/src/modules/statistics/ui/__init__.py @@ -1,2 +1,3 @@ from .profile import ProfileUI from .weeklymonthly import WeeklyMonthlyUI +from .leaderboard import LeaderboardUI diff --git a/src/modules/statistics/ui/leaderboard.py b/src/modules/statistics/ui/leaderboard.py index e69de29b..f8deb54b 100644 --- a/src/modules/statistics/ui/leaderboard.py +++ b/src/modules/statistics/ui/leaderboard.py @@ -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 + ) diff --git a/src/modules/statistics/ui/profile.py b/src/modules/statistics/ui/profile.py index 31dd47be..a032f49e 100644 --- a/src/modules/statistics/ui/profile.py +++ b/src/modules/statistics/ui/profile.py @@ -13,8 +13,10 @@ from utils.lib import MessageArgs from utils.ui import LeoUI, ModalRetryUI, FastModal, error_handler_for from babel.translator import ctx_translator from gui.cards import ProfileCard, StatsCard +from gui.base import CardMode from ..graphics.stats import get_stats_card +from ..graphics.profile import get_profile_card from ..data import StatsData from .. import babel @@ -101,6 +103,16 @@ class StatType(IntEnum): ) return name + @property + def card_mode(self): + # TODO: Need to support VOICE separately from STUDY + if self is self.VOICE: + return CardMode.VOICE + elif self is self.TEXT: + return CardMode.TEXT + elif self is self.ANKI: + return CardMode.ANKI + class ProfileUI(StatsUI): def __init__(self, bot, user, guild, **kwargs): @@ -109,6 +121,7 @@ class ProfileUI(StatsUI): # State self._stat_type = StatType.VOICE self._showing_stats = False + self._stat_message = None # Card data for rendering self._profile_card: Optional[ProfileCard] = None @@ -181,7 +194,11 @@ class ProfileUI(StatsUI): await press.response.send_modal(modal) async def edit_button_refresh(self): - ... + t = self.bot.translator.t + self.edit_button.label = t(_p( + 'ui:profile_card|button:edit|label', + "Edit Profile Badges" + )) @button(label="Show Statistics", style=ButtonStyle.blurple) async def stats_button(self, press: discord.Interaction, pressed: Button): @@ -189,12 +206,8 @@ class ProfileUI(StatsUI): Press to show or hide the statistics panel. """ self._showing_stats = not self._showing_stats - if self._stats_card or not self._showing_stats: - await press.response.defer() - await self.refresh() - else: - await press.response.defer(thinking=True, ephemeral=True) - await self.refresh(thinking=press) + await press.response.defer(thinking=True, ephemeral=True) + await self.refresh(thinking=press) async def stats_button_refresh(self): button = self.stats_button @@ -243,6 +256,19 @@ class ProfileUI(StatsUI): else: button.label = "Global Statistics" + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def close_button(self, press: discord.Interaction, pressed: Button): + """ + Delete the output message and close the UI. + """ + await press.response.defer() + await self._original.delete_original_response() + if self._stat_message is not None: + await self._stat_message.delete() + self._stat_message = None + self._original = None + await self.close() + async def refresh_components(self): """ Refresh each UI component, and the overall layout. @@ -268,7 +294,7 @@ class ProfileUI(StatsUI): """ Create and render the profile card. """ - card = await get_stats_card(self.bot, self.userid, self.guildid) + card = await get_stats_card(self.bot, self.userid, self.guildid, self._stat_type.card_mode) await card.render() self._stats_card = card return card @@ -277,12 +303,10 @@ class ProfileUI(StatsUI): """ Create and render the XP and stats cards. """ - args = await ProfileCard.sample_args(None) - data: StatsData = self.bot.get_cog('StatsCog').data - args |= {'badges': await data.ProfileTag.fetch_tags(self.guildid, self.userid)} - card = ProfileCard(**args) - await card.render() - self._profile_card = card + card = await get_profile_card(self.bot, self.userid, self.guildid) + if card: + await card.render() + self._profile_card = card return card async def reload(self): @@ -302,16 +326,39 @@ class ProfileUI(StatsUI): if tasks: await asyncio.gather(*tasks) + async def redraw(self, thinking: Optional[discord.Interaction] = None): + """ + Redraw the UI. + + If a thinking interaction is provided, + deletes the response while redrawing. + """ + profile_args, stat_args = await self.make_message() + if thinking is not None and not thinking.is_expired() and thinking.response.is_done(): + asyncio.create_task(thinking.delete_original_response()) + if stat_args is not None: + send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=None)) + if self._stat_message is None: + self._stat_message = await self._original.followup.send(**stat_args.send_args, view=self) + else: + await self._stat_message.edit(**stat_args.edit_args, view=self) + else: + send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=self)) + if self._stat_message is not None: + await self._stat_message.delete() + self._stat_message = None + await send_task + async def make_message(self) -> MessageArgs: """ Make the message arguments. Apply cache where possible. """ - # Build the final message arguments - files = [] - files.append(self._profile_card.as_file('profile.png')) + profile_args = MessageArgs(file=self._profile_card.as_file('profile.png')) if self._showing_stats: - files.append(self._stats_card.as_file('stats.png')) - return MessageArgs(files=files) + stats_args = MessageArgs(file=self._stats_card.as_file('stats.png')) + else: + stats_args = None + return (profile_args, stats_args) async def run(self, interaction: discord.Interaction): """