notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }