ui.rs (15867B)
1 use egui::FontId; 2 use egui::RichText; 3 4 use std::time::Duration; 5 use std::time::Instant; 6 7 use nostrdb::Transaction; 8 use notedeck::{ 9 AppContext, abbrev::floor_char_boundary, name::get_display_name, profile::get_profile_url, 10 }; 11 use notedeck_ui::ProfilePic; 12 13 use crate::Dashboard; 14 use crate::FxHashMap; 15 use crate::Period; 16 use crate::RollingCache; 17 use crate::chart::Bar; 18 use crate::chart::BarChartStyle; 19 use crate::chart::horizontal_bar_chart; 20 use crate::chart::palette; 21 use crate::top_kind1_authors_over; 22 use crate::top_kinds_over; 23 24 pub fn period_picker_ui(ui: &mut egui::Ui, period: &mut Period) { 25 ui.horizontal(|ui| { 26 for p in Period::ALL { 27 let selected = *period == p; 28 if ui.selectable_label(selected, p.label()).clicked() { 29 *period = p; 30 } 31 } 32 }); 33 } 34 35 pub fn dashboard_controls_ui(d: &mut Dashboard, ui: &mut egui::Ui) { 36 ui.horizontal(|ui| { 37 ui.label(egui::RichText::new("Range").small().weak()); 38 period_picker_ui(ui, &mut d.period); 39 40 ui.add_space(12.0); 41 }); 42 } 43 44 pub fn footer_status_ui( 45 ui: &mut egui::Ui, 46 running: bool, 47 err: Option<&str>, 48 last_snapshot: Option<Instant>, 49 last_duration: Option<Duration>, 50 ) { 51 ui.add_space(8.0); 52 53 if let Some(e) = err { 54 ui.label(RichText::new(e).color(ui.visuals().error_fg_color).small()); 55 return; 56 } 57 58 let mut parts: Vec<String> = Vec::new(); 59 if running { 60 parts.push("updating…".to_owned()); 61 } 62 63 if let Some(t) = last_snapshot { 64 parts.push(format!( 65 "updated {:.1?} ago", 66 Instant::now().duration_since(t) 67 )); 68 } 69 70 if let Some(d) = last_duration { 71 let ms = d.as_secs_f64() * 1000.0; 72 parts.push(format!("{ms:.0} ms")); 73 } 74 75 if parts.is_empty() { 76 parts.push("—".to_owned()); 77 } 78 79 ui.label(RichText::new(parts.join(" · ")).small().weak()); 80 } 81 82 fn card_header_ui(ui: &mut egui::Ui, title: &str) { 83 ui.horizontal(|ui| { 84 let weak = ui.visuals().weak_text_color(); 85 ui.add( 86 egui::Label::new(egui::RichText::new(title).small().color(weak)) 87 .wrap_mode(egui::TextWrapMode::Wrap), 88 ); 89 }); 90 } 91 92 pub fn card_ui( 93 ui: &mut egui::Ui, 94 min_card: f32, 95 content: impl FnOnce(&mut egui::Ui), 96 ) -> egui::Response { 97 let visuals = ui.visuals().clone(); 98 egui::Frame::group(ui.style()) 99 .fill(visuals.extreme_bg_color) 100 .corner_radius(egui::CornerRadius::same(12)) 101 .inner_margin(egui::Margin::same(12)) 102 .stroke(egui::Stroke::new( 103 1.0, 104 visuals.widgets.noninteractive.bg_stroke.color, 105 )) 106 .show(ui, |ui| { 107 ui.set_min_width(min_card); 108 ui.set_min_height(min_card * 0.5); 109 ui.vertical(|ui| { 110 content(ui); 111 }); 112 }) 113 .response 114 } 115 116 pub fn kinds_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { 117 card_header_ui(ui, "Kinds"); 118 ui.add_space(8.0); 119 120 // top kind limit, don't show more then this 121 let limit = 10; 122 123 let window_total = match dashboard.period { 124 Period::Daily => total_over(&dashboard.state.daily), 125 Period::Weekly => total_over(&dashboard.state.weekly), 126 Period::Monthly => total_over(&dashboard.state.monthly), 127 }; 128 129 let top = match dashboard.period { 130 Period::Daily => top_kinds_over(&dashboard.state.daily, limit), 131 Period::Weekly => top_kinds_over(&dashboard.state.weekly, limit), 132 Period::Monthly => top_kinds_over(&dashboard.state.monthly, limit), 133 }; 134 135 let bars = kinds_to_bars(&top); 136 137 if bars.is_empty() && window_total == 0 && dashboard.last_error.is_none() { 138 // still show something (no loading screen) 139 ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); 140 } else { 141 horizontal_bar_chart(ui, None, &bars, BarChartStyle::default()); 142 } 143 144 footer_status_ui( 145 ui, 146 dashboard.running, 147 dashboard.last_error.as_deref(), 148 dashboard.last_snapshot, 149 dashboard.last_duration, 150 ); 151 } 152 153 pub fn totals_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { 154 card_header_ui(ui, "All notes"); 155 ui.add_space(8.0); 156 157 let count: u64 = match dashboard.period { 158 Period::Daily => total_over(&dashboard.state.daily), 159 Period::Weekly => total_over(&dashboard.state.weekly), 160 Period::Monthly => total_over(&dashboard.state.monthly), 161 }; 162 163 ui.horizontal(|ui| { 164 ui.label( 165 RichText::new(count.to_string()) 166 .font(FontId::proportional(34.0)) 167 .strong(), 168 ); 169 170 ui.add_space(10.0); 171 }); 172 } 173 174 pub fn posts_per_period_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { 175 card_header_ui( 176 ui, 177 &format!("Kind 1 posts per {}", dashboard.period.label()), 178 ); 179 ui.add_space(8.0); 180 181 let cache = dashboard.selected_cache(); 182 let bars = series_bars_for_kind(dashboard.period, cache, 1); 183 184 if bars.is_empty() && dashboard.state.total.total == 0 && dashboard.last_error.is_none() { 185 ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); 186 } else if bars.is_empty() { 187 ui.label("No data"); 188 } else { 189 horizontal_bar_chart(ui, None, &bars, BarChartStyle::default()); 190 } 191 192 footer_status_ui( 193 ui, 194 dashboard.running, 195 dashboard.last_error.as_deref(), 196 dashboard.last_snapshot, 197 dashboard.last_duration, 198 ); 199 } 200 201 fn kinds_to_bars(top_kinds: &[(u64, u64)]) -> Vec<Bar> { 202 top_kinds 203 .iter() 204 .enumerate() 205 .map(|(i, (k, c))| Bar { 206 label: format!("{k}"), 207 value: *c as f32, 208 color: palette(i), 209 }) 210 .collect() 211 } 212 213 fn month_label(year: i32, month: u32) -> String { 214 // e.g. "Jan ’26" when year differs, otherwise just "Jan" would be 215 // ambiguous across years We'll always include the year suffix to 216 // keep it clear when the range crosses years. 217 const NAMES: [&str; 12] = [ 218 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", 219 ]; 220 let name = NAMES[(month.saturating_sub(1)) as usize]; 221 let yy = (year % 100).abs(); 222 format!("{name} \u{2019}{yy:02}") 223 } 224 225 fn bucket_label(period: Period, start_ts: i64, end_ts: i64) -> String { 226 use chrono::{Datelike, TimeZone, Utc}; 227 228 // end-1 keeps labels stable at boundaries 229 let default_label = "—"; 230 let Some(end_dt) = Utc.timestamp_opt(end_ts.saturating_sub(1), 0).single() else { 231 return default_label.to_owned(); 232 }; 233 234 match period { 235 Period::Daily => end_dt.format("%b %d").to_string(), 236 Period::Weekly => match Utc.timestamp_opt(start_ts, 0).single() { 237 Some(s) => format!("{}-{}", s.format("%b %d"), end_dt.format("%b %d")), 238 None => default_label.to_owned(), 239 }, 240 Period::Monthly => month_label(end_dt.year(), end_dt.month()), 241 } 242 } 243 244 fn series_bars_for_kind(period: Period, cache: &RollingCache, kind: u64) -> Vec<Bar> { 245 let n = cache.buckets.len(); 246 let mut out = Vec::with_capacity(n); 247 248 for i in (0..n).rev() { 249 let end_ts = cache.bucket_end_ts(i); 250 let start_ts = cache.bucket_start_ts(i); 251 252 let label = bucket_label(period, start_ts, end_ts); 253 254 let count = *cache.buckets[i].kinds.get(&kind).unwrap_or(&0) as f32; 255 256 out.push(Bar { 257 label, 258 value: count, 259 color: palette(out.len()), 260 }); 261 } 262 263 out 264 } 265 266 // Count totals 267 fn total_over(cache: &RollingCache) -> u64 { 268 cache.buckets.iter().map(|b| b.total).sum() 269 } 270 271 pub fn dashboard_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { 272 egui::Frame::new() 273 .inner_margin(egui::Margin::same(20)) 274 .show(ui, |ui| { 275 egui::ScrollArea::vertical().show(ui, |ui| { 276 dashboard_ui_inner(dashboard, ui, ctx); 277 }); 278 }); 279 } 280 281 fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { 282 let min_card = 240.0; 283 let gap = 8.0; 284 285 dashboard_controls_ui(dashboard, ui); 286 287 ui.with_layout( 288 egui::Layout::left_to_right(egui::Align::TOP).with_main_wrap(true), 289 |ui| { 290 ui.spacing_mut().item_spacing = egui::vec2(gap, gap); 291 let size = [min_card, min_card]; 292 ui.add_sized(size, |ui: &mut egui::Ui| { 293 card_ui(ui, min_card, |ui| totals_ui(dashboard, ui)) 294 }); 295 ui.add_sized(size, |ui: &mut egui::Ui| { 296 card_ui(ui, min_card, |ui| posts_per_period_ui(dashboard, ui)) 297 }); 298 ui.add_sized(size, |ui: &mut egui::Ui| { 299 card_ui(ui, min_card, |ui| kinds_ui(dashboard, ui)) 300 }); 301 ui.add_sized(size, |ui: &mut egui::Ui| { 302 card_ui(ui, min_card, |ui| clients_stack_ui(dashboard, ui)) 303 }); 304 ui.add_sized(size, |ui: &mut egui::Ui| { 305 card_ui(ui, min_card, |ui| clients_trends_ui(dashboard, ui)) 306 }); 307 ui.add_sized(size, |ui: &mut egui::Ui| { 308 card_ui(ui, min_card, |ui| top_posters_ui(dashboard, ui, ctx)) 309 }); 310 }, 311 ); 312 } 313 314 fn client_series(cache: &RollingCache, client: &str) -> Vec<f32> { 315 // left=oldest, right=newest like your series_bars_for_kind does 316 let n = cache.buckets.len(); 317 let mut out = Vec::with_capacity(n); 318 for i in (0..n).rev() { 319 let v = *cache.buckets[i].clients.get(client).unwrap_or(&0) as f32; 320 out.push(v); 321 } 322 out 323 } 324 325 pub fn clients_trends_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { 326 card_header_ui(ui, "Clients (trend)"); 327 ui.add_space(8.0); 328 329 let limit = 10; 330 331 let cache = match dashboard.period { 332 Period::Daily => &dashboard.state.daily, 333 Period::Weekly => &dashboard.state.weekly, 334 Period::Monthly => &dashboard.state.monthly, 335 }; 336 337 let top = top_clients_over(cache, limit); // your existing “top N” is fine as a selector 338 if top.is_empty() && dashboard.last_error.is_none() { 339 ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); 340 return; 341 } 342 if top.is_empty() { 343 ui.label("No client tags"); 344 return; 345 } 346 347 let spark_w = (ui.available_width() - 140.0).max(80.0); 348 let spark_h = 18.0; 349 350 for (row_i, (client, total)) in top.iter().enumerate() { 351 ui.horizontal(|ui| { 352 ui.label(RichText::new(client).small()); 353 ui.add_space(6.0); 354 355 let series = client_series(cache, client); 356 357 let resp = crate::sparkline::sparkline( 358 ui, 359 egui::vec2(spark_w, spark_h), 360 &series, 361 palette(row_i), 362 crate::sparkline::SparkStyle::default(), 363 ); 364 365 // tooltip: last bucket + total 366 if resp.hovered() { 367 let last = series.last().copied().unwrap_or(0.0); 368 resp.on_hover_text(format!("total: {total}\nlatest bucket: {:.0}", last)); 369 } 370 371 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 372 ui.label(RichText::new(total.to_string()).small().strong()); 373 }); 374 }); 375 ui.add_space(4.0); 376 } 377 378 footer_status_ui( 379 ui, 380 dashboard.running, 381 dashboard.last_error.as_deref(), 382 dashboard.last_snapshot, 383 dashboard.last_duration, 384 ); 385 } 386 387 fn stacked_clients_over_time( 388 cache: &RollingCache, 389 top: &[(String, u64)], 390 ) -> Vec<Vec<(egui::Color32, f32)>> { 391 let n = cache.buckets.len(); 392 let mut out = Vec::with_capacity(n); 393 394 // oldest -> newest 395 for i in (0..n).rev() { 396 let mut segs = Vec::with_capacity(top.len()); 397 for (idx, (name, _)) in top.iter().enumerate() { 398 let v = *cache.buckets[i].clients.get(name).unwrap_or(&0) as f32; 399 segs.push((palette(idx), v)); 400 } 401 out.push(segs); 402 } 403 out 404 } 405 406 pub fn clients_stack_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { 407 card_header_ui(ui, "Clients (stacked over time)"); 408 ui.add_space(8.0); 409 410 let limit = 6; // stacked charts get noisy fast; 5–7 is usually sweet spot 411 412 let cache = dashboard.selected_cache(); 413 let top = top_clients_over(cache, limit); 414 415 if top.is_empty() && dashboard.last_error.is_none() { 416 ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); 417 } else if top.is_empty() { 418 ui.label("No client tags"); 419 } else { 420 let buckets = stacked_clients_over_time(cache, &top); 421 let w = ui.available_width().max(120.0); 422 let h = 70.0; 423 424 let resp = crate::chart::stacked_bars(ui, egui::vec2(w, h), &buckets); 425 426 // legend 427 ui.add_space(6.0); 428 ui.horizontal_wrapped(|ui| { 429 for (i, (name, _)) in top.iter().enumerate() { 430 ui.label(RichText::new("■").color(palette(i))); 431 ui.label(RichText::new(name).small()); 432 ui.add_space(10.0); 433 } 434 }); 435 436 // you can also attach hover-to-bucket tooltip later if you want (based on pointer x -> bucket index) 437 let _ = resp; 438 } 439 } 440 441 fn top_clients_over(cache: &RollingCache, limit: usize) -> Vec<(String, u64)> { 442 let mut agg: FxHashMap<String, u64> = FxHashMap::default(); 443 444 for b in &cache.buckets { 445 for (client, count) in &b.clients { 446 *agg.entry(client.clone()).or_default() += *count as u64; 447 } 448 } 449 450 let mut out: Vec<(String, u64)> = agg.into_iter().collect(); 451 452 // sort desc by count; tie-break by name for stability 453 out.sort_by(|(a_name, a_cnt), (b_name, b_cnt)| { 454 b_cnt.cmp(a_cnt).then_with(|| a_name.cmp(b_name)) 455 }); 456 457 out.truncate(limit); 458 out 459 } 460 461 pub fn top_posters_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { 462 let cache = dashboard.selected_cache(); 463 let n = cache.buckets.len(); 464 let unit = dashboard.period.label(); 465 let header = format!("Top Posters ({n} {unit}s)"); 466 card_header_ui(ui, &header); 467 ui.add_space(8.0); 468 469 let limit = 10; 470 let top = top_kind1_authors_over(cache, limit); 471 472 if top.is_empty() && dashboard.last_error.is_none() { 473 ui.label(RichText::new("...").font(FontId::proportional(24.0)).weak()); 474 return; 475 } 476 477 let txn = match Transaction::new(ctx.ndb) { 478 Ok(t) => t, 479 Err(_) => { 480 ui.label("DB error"); 481 return; 482 } 483 }; 484 485 let pfp_size = ProfilePic::small_size() as f32; 486 487 for (pubkey, count) in &top { 488 let profile = ctx.ndb.get_profile_by_pubkey(&txn, pubkey.bytes()).ok(); 489 let name = get_display_name(profile.as_ref()); 490 let pfp_url = get_profile_url(profile.as_ref()); 491 492 ui.horizontal(|ui| { 493 ui.add( 494 &mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), pfp_url) 495 .size(pfp_size), 496 ); 497 ui.add_space(6.0); 498 499 let display = name.name(); 500 let truncated = if display.len() > 16 { 501 let end = floor_char_boundary(display, 16); 502 format!("{}...", &display[..end]) 503 } else { 504 display.to_string() 505 }; 506 ui.label(RichText::new(truncated).small()); 507 508 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 509 ui.label(RichText::new(count.to_string()).small().strong()); 510 }); 511 }); 512 ui.add_space(4.0); 513 } 514 515 footer_status_ui( 516 ui, 517 dashboard.running, 518 dashboard.last_error.as_deref(), 519 dashboard.last_snapshot, 520 dashboard.last_duration, 521 ); 522 }