aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands/leaderboard.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/commands/leaderboard.rs')
-rw-r--r--src/commands/leaderboard.rs329
1 files changed, 329 insertions, 0 deletions
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 @@
1use poise::serenity_prelude as serenity;
2
3use crate::Data;
4use crate::database;
5use crate::error::{Error, Result};
6
7type Context<'a> = poise::Context<'a, Data, Error>;
8
9const PAGE_SIZE: i64 = 10;
10
11/// Format seconds to human-readable duration
12fn format_duration(seconds: f64) -> String {
13 let total = seconds as u64;
14 let hours = total / 3600;
15 let minutes = (total % 3600) / 60;
16 if hours > 0 {
17 format!("{}h {}m", hours, minutes)
18 } else {
19 format!("{}m", minutes)
20 }
21}
22
23/// Format number with thousands separator
24fn format_number(n: i64) -> String {
25 let s = n.to_string();
26 let mut result = String::new();
27 for (i, c) in s.chars().rev().enumerate() {
28 if i > 0 && i % 3 == 0 {
29 result.push(',');
30 }
31 result.push(c);
32 }
33 result.chars().rev().collect()
34}
35
36/// Get rank suffix (1st, 2nd, 3rd, etc.)
37fn rank_suffix(rank: i64) -> String {
38 let suffix = if rank % 100 >= 11 && rank % 100 <= 13 {
39 "th"
40 } else {
41 match rank % 10 {
42 1 => "st",
43 2 => "nd",
44 3 => "rd",
45 _ => "th",
46 }
47 };
48 format!("{}{}", rank, suffix)
49}
50
51/// Resolve user ID to display name
52async fn resolve_display_name(ctx: Context<'_>, user_id: &str) -> String {
53 let id = user_id.parse::<u64>().unwrap_or(0);
54 let user_id_obj = serenity::UserId::new(id);
55
56 // Try to get from cache first
57 if let Some(user) = ctx.cache().user(user_id_obj) {
58 return user.name.clone();
59 }
60
61 // Fallback to API
62 if let Ok(user) = user_id_obj.to_user(ctx.http()).await {
63 return user.name;
64 }
65
66 user_id.to_string()
67}
68
69/// Create the stats embed for a user
70async fn create_stats_embed(
71 ctx: Context<'_>,
72 user_id: &str,
73 stats: &database::UserStats,
74) -> serenity::CreateEmbed {
75 let display_name = resolve_display_name(ctx, user_id).await;
76
77 // Get ranks for each metric
78 let msg_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "messages").await.unwrap_or(0);
79 let active_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "active").await.unwrap_or(0);
80 let afk_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "afk").await.unwrap_or(0);
81 let total_rank = database::get_user_rank(&ctx.data().db_pool, user_id, "total").await.unwrap_or(0);
82
83 let rank_str = |rank: i64| if rank > 0 { format!(" (#{})", rank_suffix(rank)) } else { " (unranked)".to_string() };
84
85 let total_voice = stats.total_voice_active + stats.total_voice_afk;
86
87 serenity::CreateEmbed::default()
88 .title(format!("Stats for {}", display_name))
89 .color(serenity::Color::from_rgb(59, 130, 246))
90 .field(
91 "Messages",
92 format!("{}{}", format_number(stats.message_count), rank_str(msg_rank)),
93 true,
94 )
95 .field(
96 "Voice Active",
97 format!("{}{}", format_duration(stats.total_voice_active), rank_str(active_rank)),
98 true,
99 )
100 .field(
101 "Voice AFK",
102 format!("{}{}", format_duration(stats.total_voice_afk), rank_str(afk_rank)),
103 true,
104 )
105 .field(
106 "Total Voice",
107 format!("{}{}", format_duration(total_voice), rank_str(total_rank)),
108 true,
109 )
110 .footer(serenity::CreateEmbedFooter::new("Use /leaderboard messages|active|afk|total to see full leaderboard"))
111}
112
113/// Create the leaderboard embed
114async fn create_leaderboard_embed(
115 ctx: Context<'_>,
116 metric: &str,
117 ascending: bool,
118 page: i64,
119) -> Result<(serenity::CreateEmbed, i64)> {
120 let offset = (page - 1) * PAGE_SIZE;
121 let (entries, total_count) = database::get_leaderboard(
122 &ctx.data().db_pool,
123 metric,
124 ascending,
125 PAGE_SIZE,
126 offset,
127 )
128 .await?;
129
130 let total_pages = (total_count as f64 / PAGE_SIZE as f64).ceil() as i64;
131 let total_pages = total_pages.max(1);
132
133 let title = match metric {
134 "messages" => "Messages Leaderboard",
135 "active" => "Voice Active Leaderboard",
136 "afk" => "Voice AFK Leaderboard",
137 "total" => "Total Voice Leaderboard",
138 _ => "Leaderboard",
139 };
140
141 let direction = if ascending { "↑" } else { "↓" };
142
143 let mut description = String::new();
144 for (i, entry) in entries.iter().enumerate() {
145 let rank = offset + i as i64 + 1;
146 let display_name = resolve_display_name(ctx, &entry.user_id).await;
147
148 let value = match metric {
149 "messages" => format_number(entry.message_count),
150 "active" => format_duration(entry.total_voice_active),
151 "afk" => format_duration(entry.total_voice_afk),
152 "total" => format_duration(entry.total_voice_active + entry.total_voice_afk),
153 _ => format_number(entry.message_count),
154 };
155
156 description.push_str(&format!("**#{}** — {} — {}\n", rank_suffix(rank), display_name, value));
157 }
158
159 if description.is_empty() {
160 description = "No entries yet.".to_string();
161 }
162
163 // Get current user's rank and stats for footer
164 let user_id = ctx.author().id.to_string();
165 let user_stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?;
166 let user_rank = database::get_user_rank(&ctx.data().db_pool, &user_id, metric).await.unwrap_or(0);
167
168 let footer_text = if let Some(stats) = &user_stats {
169 let user_value = match metric {
170 "messages" => format_number(stats.message_count),
171 "active" => format_duration(stats.total_voice_active),
172 "afk" => format_duration(stats.total_voice_afk),
173 "total" => format_duration(stats.total_voice_active + stats.total_voice_afk),
174 _ => format_number(stats.message_count),
175 };
176 if user_rank > 0 {
177 format!("Your rank: #{} ({})", rank_suffix(user_rank), user_value)
178 } else {
179 "You are not ranked yet".to_string()
180 }
181 } else {
182 "You are not ranked yet".to_string()
183 };
184
185 let embed = serenity::CreateEmbed::default()
186 .title(format!("{} {}", title, direction))
187 .description(description)
188 .color(serenity::Color::from_rgb(59, 130, 246))
189 .footer(serenity::CreateEmbedFooter::new(footer_text))
190 .field("Page", format!("{}/{}", page, total_pages), true);
191
192 Ok((embed, total_pages))
193}
194
195/// Show your personal stats
196#[poise::command(slash_command)]
197pub async fn me(ctx: Context<'_>) -> Result<()> {
198 let user_id = ctx.author().id.to_string();
199 let stats = database::get_user_stats(&ctx.data().db_pool, &user_id).await?;
200
201 let embed = if let Some(stats) = &stats {
202 create_stats_embed(ctx, &user_id, stats).await
203 } else {
204 // User has no stats yet - show zeros
205 let empty_stats = database::UserStats {
206 user_id: user_id.clone(),
207 message_count: 0,
208 total_voice_active: 0.0,
209 total_voice_afk: 0.0,
210 };
211 create_stats_embed(ctx, &user_id, &empty_stats).await
212 };
213
214 ctx.send(
215 poise::CreateReply::default()
216 .embed(embed),
217 )
218 .await?;
219 Ok(())
220}
221
222/// Show messages leaderboard
223#[poise::command(slash_command)]
224pub async fn messages(
225 ctx: Context<'_>,
226 #[description = "Sort order (default: descending)"]
227 #[autocomplete = "sort_order_autocomplete"]
228 order: Option<String>,
229) -> Result<()> {
230 let ascending = order.as_deref() == Some("ascending");
231 let page = 1;
232
233 let (embed, _total_pages) = create_leaderboard_embed(ctx, "messages", ascending, page).await?;
234
235 ctx.send(
236 poise::CreateReply::default()
237 .embed(embed),
238 )
239 .await?;
240 Ok(())
241}
242
243/// Show voice active leaderboard
244#[poise::command(slash_command)]
245pub async fn active(
246 ctx: Context<'_>,
247 #[description = "Sort order (default: descending)"]
248 #[autocomplete = "sort_order_autocomplete"]
249 order: Option<String>,
250) -> Result<()> {
251 let ascending = order.as_deref() == Some("ascending");
252 let page = 1;
253
254 let (embed, _total_pages) = create_leaderboard_embed(ctx, "active", ascending, page).await?;
255
256 ctx.send(
257 poise::CreateReply::default()
258 .embed(embed),
259 )
260 .await?;
261 Ok(())
262}
263
264/// Show voice AFK leaderboard
265#[poise::command(slash_command)]
266pub async fn afk(
267 ctx: Context<'_>,
268 #[description = "Sort order (default: descending)"]
269 #[autocomplete = "sort_order_autocomplete"]
270 order: Option<String>,
271) -> Result<()> {
272 let ascending = order.as_deref() == Some("ascending");
273 let page = 1;
274
275 let (embed, _total_pages) = create_leaderboard_embed(ctx, "afk", ascending, page).await?;
276
277 ctx.send(
278 poise::CreateReply::default()
279 .embed(embed),
280 )
281 .await?;
282 Ok(())
283}
284
285/// Show total voice leaderboard
286#[poise::command(slash_command)]
287pub async fn total(
288 ctx: Context<'_>,
289 #[description = "Sort order (default: descending)"]
290 #[autocomplete = "sort_order_autocomplete"]
291 order: Option<String>,
292) -> Result<()> {
293 let ascending = order.as_deref() == Some("ascending");
294 let page = 1;
295
296 let (embed, _total_pages) = create_leaderboard_embed(ctx, "total", ascending, page).await?;
297
298 ctx.send(
299 poise::CreateReply::default()
300 .embed(embed),
301 )
302 .await?;
303 Ok(())
304}
305
306/// Autocomplete for sort order
307async fn sort_order_autocomplete(
308 _ctx: Context<'_>,
309 _partial: &str,
310) -> Vec<serenity::AutocompleteChoice> {
311 vec![
312 serenity::AutocompleteChoice::new("Descending (highest first)", "descending"),
313 serenity::AutocompleteChoice::new("Ascending (lowest first)", "ascending"),
314 ]
315}
316
317/// Leaderboard commands
318#[poise::command(
319 slash_command,
320 subcommands("me", "messages", "active", "afk", "total"),
321 subcommand_required
322)]
323pub async fn leaderboard(_ctx: Context<'_>) -> Result<()> {
324 Ok(())
325}
326
327pub fn commands() -> Vec<poise::Command<Data, Error>> {
328 vec![leaderboard()]
329}