From 11507d00fb774e59afe2c304a6922f875865caee Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 19 Aug 2026 19:28:39 +0200 Subject: feat: leaderboads --- src/commands/leaderboard.rs | 329 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 src/commands/leaderboard.rs (limited to 'src/commands/leaderboard.rs') diff --git a/src/commands/leaderboard.rs b/src/commands/leaderboard.rs new file mode 100644 index 0000000..94d67d1 --- /dev/null +++ b/src/commands/leaderboard.rs @@ -0,0 +1,329 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::database; +use crate::error::{Error, Result}; + +type Context<'a> = poise::Context<'a, Data, Error>; + +const PAGE_SIZE: i64 = 10; + +/// Format seconds to human-readable duration +fn format_duration(seconds: f64) -> String { + let total = seconds as u64; + let hours = total / 3600; + let minutes = (total % 3600) / 60; + if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } +} + +/// Format number with thousands separator +fn format_number(n: i64) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} + +/// Get rank suffix (1st, 2nd, 3rd, etc.) +fn rank_suffix(rank: i64) -> String { + let suffix = if rank % 100 >= 11 && rank % 100 <= 13 { + "th" + } else { + match rank % 10 { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + } + }; + format!("{}{}", rank, suffix) +} + +/// Resolve user ID to display name +async fn resolve_display_name(ctx: Context<'_>, user_id: &str) -> String { + let id = user_id.parse::().unwrap_or(0); + let user_id_obj = serenity::UserId::new(id); + + // Try to get from cache first + if let Some(user) = ctx.cache().user(user_id_obj) { + return user.name.clone(); + } + + // Fallback to API + if let Ok(user) = user_id_obj.to_user(ctx.http()).await { + return user.name; + } + + user_id.to_string() +} + +/// Create the stats embed for a user +async fn create_stats_embed( + ctx: Context<'_>, + user_id: &str, + stats: &database::UserStats, +) -> serenity::CreateEmbed { + let display_name = resolve_display_name(ctx, user_id).await; + + // Get ranks for each metric + let msg_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "messages").await.unwrap_or(0); + let active_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "active").await.unwrap_or(0); + let afk_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "afk").await.unwrap_or(0); + let total_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "total").await.unwrap_or(0); + + let rank_str = |rank: i64| if rank > 0 { format!(" (#{})", rank_suffix(rank)) } else { " (unranked)".to_string() }; + + let total_voice = stats.total_voice_active + stats.total_voice_afk; + + serenity::CreateEmbed::default() + .title(format!("Stats for {}", display_name)) + .color(serenity::Color::from_rgb(59, 130, 246)) + .field( + "Messages", + format!("{}{}", format_number(stats.message_count), rank_str(msg_rank)), + true, + ) + .field( + "Voice Active", + format!("{}{}", format_duration(stats.total_voice_active), rank_str(active_rank)), + true, + ) + .field( + "Voice AFK", + format!("{}{}", format_duration(stats.total_voice_afk), rank_str(afk_rank)), + true, + ) + .field( + "Total Voice", + format!("{}{}", format_duration(total_voice), rank_str(total_rank)), + true, + ) + .footer(serenity::CreateEmbedFooter::new("Use /leaderboard messages|active|afk|total to see full leaderboard")) +} + +/// Create the leaderboard embed +async fn create_leaderboard_embed( + ctx: Context<'_>, + metric: &str, + ascending: bool, + page: i64, +) -> Result<(serenity::CreateEmbed, i64)> { + let offset = (page - 1) * PAGE_SIZE; + let (entries, total_count) = database::get_leaderboard( + &ctx.data().db_pool, + metric, + ascending, + PAGE_SIZE, + offset, + ) + .await?; + + let total_pages = (total_count as f64 / PAGE_SIZE as f64).ceil() as i64; + let total_pages = total_pages.max(1); + + let title = match metric { + "messages" => "Messages Leaderboard", + "active" => "Voice Active Leaderboard", + "afk" => "Voice AFK Leaderboard", + "total" => "Total Voice Leaderboard", + _ => "Leaderboard", + }; + + let direction = if ascending { "↑" } else { "↓" }; + + let mut description = String::new(); + for (i, entry) in entries.iter().enumerate() { + let rank = offset + i as i64 + 1; + let display_name = resolve_display_name(ctx, &entry.user_id).await; + + let value = match metric { + "messages" => format_number(entry.message_count), + "active" => format_duration(entry.total_voice_active), + "afk" => format_duration(entry.total_voice_afk), + "total" => format_duration(entry.total_voice_active + entry.total_voice_afk), + _ => format_number(entry.message_count), + }; + + description.push_str(&format!("**#{}** — {} — {}\n", rank_suffix(rank), display_name, value)); + } + + if description.is_empty() { + description = "No entries yet.".to_string(); + } + + // Get current user's rank and stats for footer + let user_id = ctx.author().id.to_string(); + let user_stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; + let user_rank = database::get_user_rank(&ctx.data().db_pool, &user_id, metric).await.unwrap_or(0); + + let footer_text = if let Some(stats) = &user_stats { + let user_value = match metric { + "messages" => format_number(stats.message_count), + "active" => format_duration(stats.total_voice_active), + "afk" => format_duration(stats.total_voice_afk), + "total" => format_duration(stats.total_voice_active + stats.total_voice_afk), + _ => format_number(stats.message_count), + }; + if user_rank > 0 { + format!("Your rank: #{} ({})", rank_suffix(user_rank), user_value) + } else { + "You are not ranked yet".to_string() + } + } else { + "You are not ranked yet".to_string() + }; + + let embed = serenity::CreateEmbed::default() + .title(format!("{} {}", title, direction)) + .description(description) + .color(serenity::Color::from_rgb(59, 130, 246)) + .footer(serenity::CreateEmbedFooter::new(footer_text)) + .field("Page", format!("{}/{}", page, total_pages), true); + + Ok((embed, total_pages)) +} + +/// Show your personal stats +#[poise::command(slash_command)] +pub async fn me(ctx: Context<'_>) -> Result<()> { + let user_id = ctx.author().id.to_string(); + let stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?; + + let embed = if let Some(stats) = &stats { + create_stats_embed(ctx, &user_id, stats).await + } else { + // User has no stats yet - show zeros + let empty_stats = database::UserStats { + user_id: user_id.clone(), + message_count: 0, + total_voice_active: 0.0, + total_voice_afk: 0.0, + }; + create_stats_embed(ctx, &user_id, &empty_stats).await + }; + + ctx.send( + poise::CreateReply::default() + .embed(embed), + ) + .await?; + Ok(()) +} + +/// Show messages leaderboard +#[poise::command(slash_command)] +pub async fn messages( + ctx: Context<'_>, + #[description = "Sort order (default: descending)"] + #[autocomplete = "sort_order_autocomplete"] + order: Option, +) -> Result<()> { + let ascending = order.as_deref() == Some("ascending"); + let page = 1; + + let (embed, _total_pages) = create_leaderboard_embed(ctx, "messages", ascending, page).await?; + + ctx.send( + poise::CreateReply::default() + .embed(embed), + ) + .await?; + Ok(()) +} + +/// Show voice active leaderboard +#[poise::command(slash_command)] +pub async fn active( + ctx: Context<'_>, + #[description = "Sort order (default: descending)"] + #[autocomplete = "sort_order_autocomplete"] + order: Option, +) -> Result<()> { + let ascending = order.as_deref() == Some("ascending"); + let page = 1; + + let (embed, _total_pages) = create_leaderboard_embed(ctx, "active", ascending, page).await?; + + ctx.send( + poise::CreateReply::default() + .embed(embed), + ) + .await?; + Ok(()) +} + +/// Show voice AFK leaderboard +#[poise::command(slash_command)] +pub async fn afk( + ctx: Context<'_>, + #[description = "Sort order (default: descending)"] + #[autocomplete = "sort_order_autocomplete"] + order: Option, +) -> Result<()> { + let ascending = order.as_deref() == Some("ascending"); + let page = 1; + + let (embed, _total_pages) = create_leaderboard_embed(ctx, "afk", ascending, page).await?; + + ctx.send( + poise::CreateReply::default() + .embed(embed), + ) + .await?; + Ok(()) +} + +/// Show total voice leaderboard +#[poise::command(slash_command)] +pub async fn total( + ctx: Context<'_>, + #[description = "Sort order (default: descending)"] + #[autocomplete = "sort_order_autocomplete"] + order: Option, +) -> Result<()> { + let ascending = order.as_deref() == Some("ascending"); + let page = 1; + + let (embed, _total_pages) = create_leaderboard_embed(ctx, "total", ascending, page).await?; + + ctx.send( + poise::CreateReply::default() + .embed(embed), + ) + .await?; + Ok(()) +} + +/// Autocomplete for sort order +async fn sort_order_autocomplete( + _ctx: Context<'_>, + _partial: &str, +) -> Vec { + vec![ + serenity::AutocompleteChoice::new("Descending (highest first)", "descending"), + serenity::AutocompleteChoice::new("Ascending (lowest first)", "ascending"), + ] +} + +/// Leaderboard commands +#[poise::command( + slash_command, + subcommands("me", "messages", "active", "afk", "total"), + subcommand_required +)] +pub async fn leaderboard(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +pub fn commands() -> Vec> { + vec![leaderboard()] +} -- cgit v1.2.3