notedeck

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

commit d7551645d7f46e0e4d507b2cb6cae0fc19ee3022
parent 04dd8adfaef8f2b778626fec0e3fe2ac9d3b189c
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 18:03:46 -0800

Merge dashboard app

Diffstat:
MCargo.lock | 30+++++++++++++++++++++++-------
MCargo.toml | 12++++++++----
Mcrates/notedeck/Cargo.toml | 4++--
Mcrates/notedeck_chrome/Cargo.toml | 2++
Mcrates/notedeck_chrome/src/app.rs | 13+++++++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 16++++++++++++++++
Acrates/notedeck_dashboard/Cargo.toml | 17+++++++++++++++++
Acrates/notedeck_dashboard/README.md | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/chart.rs | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/lib.rs | 562+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/sparkline.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dashboard/src/ui.rs | 520+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mshell.nix | 2--
13 files changed, 1608 insertions(+), 15 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -651,7 +651,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -674,7 +674,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -3315,7 +3315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.2", ] [[package]] @@ -3845,8 +3845,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b2685d093dca579807150b6fafab4dcb974fc6e31017d273ae32d42795d41b" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=34738d2894d841ac44b1c46e0334a7cf2ca09b34#34738d2894d841ac44b1c46e0334a7cf2ca09b34" dependencies = [ "bindgen 0.69.5", "cc", @@ -3940,6 +3939,7 @@ dependencies = [ "notedeck", "notedeck_clndash", "notedeck_columns", + "notedeck_dashboard", "notedeck_dave", "notedeck_messages", "notedeck_notebook", @@ -4036,6 +4036,22 @@ dependencies = [ ] [[package]] +name = "notedeck_dashboard" +version = "0.7.1" +dependencies = [ + "chrono", + "crossbeam-channel", + "egui", + "enostr", + "nostrdb", + "notedeck", + "notedeck_ui", + "rustc-hash 2.1.1", + "tokio", + "tracing", +] + +[[package]] name = "notedeck_dave" version = "0.7.1" dependencies = [ @@ -6710,7 +6726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "319c70195101a93f56db4c74733e272d720768e13471f400c78406a326b172b0" dependencies = [ "cc", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -7492,7 +7508,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -10,13 +10,16 @@ members = [ "crates/notedeck_notebook", "crates/notedeck_ui", "crates/notedeck_clndash", - - "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/notedeck_clndash", + "crates/notedeck_dashboard", + "crates/tokenator", + "crates/enostr", ] [workspace.dependencies] opener = "0.8.2" chrono = "0.4.40" +crossbeam-channel = "0.5" +crossbeam = "0.8.4" base32 = "0.4.0" base64 = "0.22.1" rmpv = "1.3.0" @@ -38,7 +41,7 @@ hyper = { version = "1.7.0", features = ["full"] } hyper-util = {version = "0.1" , features = ["tokio"]} # hyper-rustls with NO default crypto provider (aws-lc-rs is default and requires cmake on Windows) # Crates add platform-specific features: ring on Windows, aws-lc-rs elsewhere -hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio"] } +hyper-rustls = { version = "0.27.7", default-features = false, features = ["http1", "tls12", "logging", "native-tokio", "webpki-roots"] } http-body-util = "0.1.3" # rustls with NO default crypto provider at workspace level. # Crates use platform-specific dependencies to select: @@ -59,11 +62,12 @@ md5 = "0.7.0" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } -nostrdb = "0.9.0" +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "34738d2894d841ac44b1c46e0334a7cf2ca09b34" } #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_clndash = { path = "crates/notedeck_clndash" } +notedeck_dashboard = { path = "crates/notedeck_dashboard" } notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } notedeck_messages = { path = "crates/notedeck_messages" } diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -52,8 +52,8 @@ regex = "1" chrono = { workspace = true } indexmap = {workspace = true} rand = {workspace = true} -crossbeam-channel = "0.5" -crossbeam = "0.8.4" +crossbeam-channel = { workspace = true } +crossbeam = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } http-body-util = { workspace = true } diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -21,6 +21,7 @@ notedeck_dave = { workspace = true } notedeck_messages = { workspace = true, optional = true } notedeck_notebook = { workspace = true, optional = true } notedeck_clndash = { workspace = true, optional = true } +notedeck_dashboard = { workspace = true, optional = true } notedeck = { workspace = true } nostrdb = { workspace = true } puffin = { workspace = true, optional = true } @@ -57,6 +58,7 @@ tracy = ["profiling/profile-with-tracy"] messages = ["notedeck_messages"] notebook = ["notedeck_notebook"] clndash = ["notedeck_clndash"] +dashboard = ["notedeck_dashboard"] [target.'cfg(target_os = "android")'.dependencies] tracing-logcat = "0.1.0" diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs @@ -9,6 +9,9 @@ use notedeck_clndash::ClnDash; #[cfg(feature = "messages")] use notedeck_messages::MessagesApp; +#[cfg(feature = "dashboard")] +use notedeck_dashboard::Dashboard; + #[cfg(feature = "notebook")] use notedeck_notebook::Notebook; @@ -16,12 +19,19 @@ use notedeck_notebook::Notebook; pub enum NotedeckApp { Dave(Box<Dave>), Columns(Box<Damus>), + #[cfg(feature = "notebook")] Notebook(Box<Notebook>), + #[cfg(feature = "clndash")] ClnDash(Box<ClnDash>), + #[cfg(feature = "messages")] Messages(Box<MessagesApp>), + + #[cfg(feature = "dashboard")] + Dashboard(Box<Dashboard>), + Other(Box<dyn notedeck::App>), } @@ -41,6 +51,9 @@ impl notedeck::App for NotedeckApp { #[cfg(feature = "messages")] NotedeckApp::Messages(dms) => dms.update(ctx, ui), + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(db) => db.update(ctx, ui), + NotedeckApp::Other(other) => other.update(ctx, ui), } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -30,6 +30,9 @@ use notedeck_dave::{Dave, DaveAvatar}; #[cfg(feature = "messages")] use notedeck_messages::MessagesApp; +#[cfg(feature = "dashboard")] +use notedeck_dashboard::Dashboard; + #[cfg(feature = "clndash")] use notedeck_ui::expanding_button; @@ -166,6 +169,9 @@ impl Chrome { #[cfg(feature = "messages")] chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new()))); + #[cfg(feature = "dashboard")] + chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default()))); + #[cfg(feature = "notebook")] chrome.add_app(NotedeckApp::Notebook(Box::default())); @@ -789,6 +795,11 @@ fn topdown_sidebar( tr!(loc, "Messaging", "Button to go to the messaging app") } + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(_) => { + tr!(loc, "Dashboard", "Button to go to the dashboard app") + } + #[cfg(feature = "notebook")] NotedeckApp::Notebook(_) => { tr!(loc, "Notebook", "Button to go to the Notebook app") @@ -823,6 +834,11 @@ fn topdown_sidebar( ); } + #[cfg(feature = "dashboard")] + NotedeckApp::Dashboard(_columns_app) => { + ui.add(app_images::algo_image()); + } + #[cfg(feature = "messages")] NotedeckApp::Messages(_dms) => { ui.add(app_images::new_message_image()); diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "notedeck_dashboard" +edition = "2024" +version.workspace = true + +[dependencies] +rustc-hash = "2.1.1" + +egui = { workspace = true } +enostr = { workspace = true } +nostrdb = { workspace = true } +chrono = { workspace = true } +crossbeam-channel = { workspace = true } +notedeck = { workspace = true } +notedeck_ui = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/notedeck_dashboard/README.md b/crates/notedeck_dashboard/README.md @@ -0,0 +1,102 @@ +# Notedeck Dashboard + +A minimal, real-time dashboard for **Notedeck**, built with **egui** and backed by **nostrdb**. + +This app renders live statistics from a Nostr database without blocking the UI, using a single long-lived worker thread and progressive snapshots. No loading screens, no spinners—data fades in as it’s computed. + +## What it does (today) + +* Counts **total notes** in the database +* Shows **top N event kinds** as a horizontal bar chart +* Refreshes automatically on a fixed interval +* Streams intermediate results to the UI while scanning +* Keeps previous values visible during refreshes + +## Architecture overview + +### UI thread + +* Pure egui rendering +* Never blocks on database work +* Reacts to snapshots pushed from the worker +* Requests repaints only when new data arrives + +### Worker thread + +* Single persistent thread +* Runs one scan per refresh +* Emits periodic snapshots (~30ms cadence) +* Uses a single `nostrdb::Transaction` per run +* Communicates via `crossbeam_channel` + +### Data flow + +``` +UI ── Refresh cmd ──▶ Worker +UI ◀─ Snapshot msgs ◀─ Worker +UI ◀─ Finished msg ◀─ Worker +``` + +## Code layout + +``` +src/ +├── lib.rs # App entry, worker orchestration, refresh logic +├── ui.rs # egui cards, charts, and status UI +└── chart.rs # Reusable horizontal bar chart widget +``` + +### `chart.rs` + +* Custom horizontal bar chart +* Value labels, hover tooltips, and color palette +* Designed to be generic and reusable + +### `lib.rs` + +* Implements `notedeck::App` +* Owns worker lifecycle and refresh policy +* Handles snapshot merging and UI state + +### `ui.rs` + +* Card-based layout +* Totals view + kinds bar chart +* Footer status showing freshness and timing + +## Design goals + +* **Zero UI stalls** + Database scans never block rendering. + +* **Progressive feedback** + Partial results are better than spinners. + +* **Simple concurrency** + One worker, one job at a time, explicit messaging. + +* **Low ceremony** + No async runtime, no task pools, no state machines. + +## Non-goals (for now) + +* Multiple workers +* Historical comparisons +* Persistence of dashboard state +* Configuration UI +* Fancy animations + +## Requirements + +* Rust (stable) +* `egui` +* `notedeck` +* `nostrdb` +* `crossbeam-channel` + +This crate is intended to be built and run as part of a Notedeck environment. + +## Status + +Early but functional. +The core threading, snapshotting, and rendering model is in place and intentionally conservative. Future changes should preserve the “always responsive” property above all else. diff --git a/crates/notedeck_dashboard/src/chart.rs b/crates/notedeck_dashboard/src/chart.rs @@ -0,0 +1,254 @@ +use egui::{Align2, Color32, FontId, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; + +pub fn palette(i: usize) -> Color32 { + const P: [Color32; 10] = [ + Color32::from_rgb(231, 76, 60), + Color32::from_rgb(52, 152, 219), + Color32::from_rgb(46, 204, 113), + Color32::from_rgb(155, 89, 182), + Color32::from_rgb(241, 196, 15), + Color32::from_rgb(230, 126, 34), + Color32::from_rgb(26, 188, 156), + Color32::from_rgb(149, 165, 166), + Color32::from_rgb(52, 73, 94), + Color32::from_rgb(233, 150, 122), + ]; + P[i % P.len()] +} + +// ---------------------- +// Bar chart (unchanged) +// ---------------------- + +#[derive(Debug, Clone)] +pub struct Bar { + pub label: String, + pub value: f32, + pub color: Color32, +} + +#[derive(Clone, Copy)] +pub struct BarChartStyle { + pub row_height: f32, + pub gap: f32, + pub rounding: f32, + pub show_values: bool, + pub value_precision: usize, +} + +impl Default for BarChartStyle { + fn default() -> Self { + Self { + row_height: 18.0, + gap: 6.0, + rounding: 3.0, + show_values: true, + value_precision: 0, + } + } +} + +/// Draws a horizontal bar chart. Returns the combined response so you can check hover/click if desired. +pub fn horizontal_bar_chart( + ui: &mut Ui, + title: Option<&str>, + bars: &[Bar], + style: BarChartStyle, +) -> Response { + if let Some(t) = title { + ui.label(t); + } + + if bars.is_empty() { + return ui.label("No data"); + } + + let max_v = bars + .iter() + .map(|b| b.value.max(0.0)) + .fold(0.0_f32, f32::max); + + if max_v <= 0.0 { + return ui.label("No data"); + } + + // Layout: label column + bar column + let label_col_w = ui + .fonts(|f| { + bars.iter() + .map(|b| { + f.layout_no_wrap( + b.label.to_owned(), + FontId::proportional(14.0), + ui.visuals().text_color(), + ) + .size() + .x + }) + .fold(0.0, f32::max) + }) + .ceil() + + 10.0; + + let avail_w = ui.available_width().max(50.0); + let bar_col_w = (avail_w - label_col_w).max(50.0); + + let total_h = + bars.len() as f32 * style.row_height + (bars.len().saturating_sub(1) as f32) * style.gap; + let (outer_rect, outer_resp) = + ui.allocate_exact_size(Vec2::new(avail_w, total_h), Sense::hover()); + let painter = ui.painter_at(outer_rect); + + // Optional: faint background + painter.rect_filled(outer_rect, 6.0, ui.visuals().faint_bg_color); + + let mut y = outer_rect.top(); + + for b in bars { + let row_rect = Rect::from_min_size( + Pos2::new(outer_rect.left(), y), + Vec2::new(avail_w, style.row_height), + ); + let row_resp = ui.interact( + row_rect, + ui.id().with(&b.label).with(y as i64), + Sense::hover(), + ); + + // Label (left) + let label_pos = Pos2::new(row_rect.left() + 6.0, row_rect.center().y); + painter.text( + label_pos, + Align2::LEFT_CENTER, + &b.label, + FontId::proportional(14.0), + ui.visuals().text_color(), + ); + + // Bar background track (right) + let track_rect = Rect::from_min_max( + Pos2::new(row_rect.left() + label_col_w, row_rect.top() + 2.0), + Pos2::new( + row_rect.left() + label_col_w + bar_col_w, + row_rect.bottom() - 2.0, + ), + ); + painter.rect_filled( + track_rect, + style.rounding, + ui.visuals().widgets.inactive.bg_fill, + ); + painter.rect_stroke( + track_rect, + style.rounding, + Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color), + StrokeKind::Middle, + ); + + // Filled portion + let frac = (b.value.max(0.0) / max_v).clamp(0.0, 1.0); + let fill_w = track_rect.width() * frac; + if fill_w > 0.0 { + let fill_rect = Rect::from_min_max( + track_rect.min, + Pos2::new(track_rect.min.x + fill_w, track_rect.max.y), + ); + painter.rect_filled(fill_rect, style.rounding, b.color); + } + + // Value label (right-aligned at end of track) + if style.show_values { + let txt = if style.value_precision == 0 { + format!("{:.0}", b.value) + } else { + format!("{:.*}", style.value_precision, b.value) + }; + painter.text( + Pos2::new(track_rect.right() - 6.0, row_rect.center().y), + Align2::RIGHT_CENTER, + txt, + FontId::proportional(13.0), + ui.visuals().text_color(), + ); + } + + // Tooltip on hover + if row_resp.hovered() { + let sum = bars.iter().map(|x| x.value.max(0.0)).sum::<f32>().max(1.0); + let pct = (b.value / sum) * 100.0; + row_resp.on_hover_text(format!("{}: {:.0} ({:.1}%)", b.label, b.value, pct)); + } + + y += style.row_height + style.gap; + } + + outer_resp +} + +pub fn stacked_bars( + ui: &mut Ui, + size: Vec2, + buckets: &[Vec<(Color32, f32)>], // each bucket: vec of (color, value) +) -> Response { + let (rect, resp) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(rect); + + painter.rect_filled(rect, 6.0, ui.visuals().faint_bg_color); + + if buckets.is_empty() { + return resp; + } + + // find max total per bucket for scaling + let mut max_total = 0.0_f32; + for b in buckets { + let t: f32 = b.iter().map(|(_, v)| v.max(0.0)).sum(); + max_total = max_total.max(t); + } + if max_total <= 0.0 { + return resp; + } + + let n = buckets.len(); + let bw = rect.width() / n as f32; + + for (i, b) in buckets.iter().enumerate() { + let x0 = rect.left() + i as f32 * bw; + let x1 = x0 + bw; + + let total: f32 = b.iter().map(|(_, v)| v.max(0.0)).sum(); + let h_total = rect.height() * (total / max_total); + + // Optional: center bars if you want; simplest is fill from bottom with total scaling: + let mut y0 = rect.bottom(); + let y_min = rect.bottom() - h_total; + + for (color, v) in b { + let v = v.max(0.0); + if v <= 0.0 { + continue; + } + let seg_h = h_total * (v / total.max(1.0)); + let seg_rect = Rect::from_min_max( + Pos2::new(x0 + 1.0, (y0 - seg_h).max(y_min)), + Pos2::new(x1 - 1.0, y0), + ); + painter.rect_filled(seg_rect, 0.0, *color); + y0 -= seg_h; + } + + // faint outline + let outline = Rect::from_min_max( + Pos2::new(x0 + 1.0, y_min), + Pos2::new(x1 - 1.0, rect.bottom()), + ); + painter.rect_stroke( + outline, + 0.0, + Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color), + StrokeKind::Middle, + ); + } + + resp +} diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs @@ -0,0 +1,562 @@ +use enostr::Pubkey; +use nostrdb::Note; +use rustc_hash::FxHashMap; +use std::thread; +use std::time::{Duration, Instant}; + +use crossbeam_channel as chan; + +use nostrdb::{Filter, Ndb, Transaction}; +use notedeck::{AppContext, AppResponse, try_process_events_core}; + +use chrono::{Datelike, TimeZone, Utc}; + +mod chart; +mod sparkline; +mod ui; + +// ---------------------- +// Worker protocol +// ---------------------- + +#[derive(Debug)] +enum WorkerCmd { + Refresh, + //Quit, +} + +// Buckets are multiples of time ranges +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Period { + Daily, + Weekly, + Monthly, +} + +impl Period { + pub const ALL: [Period; 3] = [Period::Daily, Period::Weekly, Period::Monthly]; + + pub fn label(self) -> &'static str { + match self { + Period::Daily => "day", + Period::Weekly => "week", + Period::Monthly => "month", + } + } +} + +/// All the data we are interested in for a specific range +#[derive(Default, Clone, Debug)] +struct Bucket { + pub total: u64, + pub kinds: rustc_hash::FxHashMap<u64, u32>, + pub clients: rustc_hash::FxHashMap<String, u32>, + pub kind1_authors: rustc_hash::FxHashMap<Pubkey, u32>, +} + +fn note_client_tag<'a>(note: &Note<'a>) -> Option<&'a str> { + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + + let Some("client") = tag.get_str(0) else { + continue; + }; + + return tag.get_str(1); + } + + None +} + +impl Bucket { + #[inline(always)] + pub fn bump(&mut self, note: &Note<'_>) { + self.total += 1; + let kind = note.kind(); + *self.kinds.entry(kind as u64).or_default() += 1; + + // Track kind1 authors + if kind == 1 { + let pk = Pubkey::new(*note.pubkey()); + *self.kind1_authors.entry(pk).or_default() += 1; + } + + if let Some(client) = note_client_tag(note) { + *self.clients.entry(client.to_string()).or_default() += 1; + } else { + // TODO(jb55): client fingerprinting ? + } + } +} + +// bucket_end_ts(idx) - self.bucket_size_secs +#[derive(Debug, Clone, Default)] +struct RollingCache { + pub bucket_size_secs: i64, + pub anchor_end_ts: i64, + pub buckets: Vec<Bucket>, +} + +impl RollingCache { + pub fn bucket_end_ts(&self, idx: usize) -> i64 { + self.anchor_end_ts - (idx as i64) * self.bucket_size_secs + } + + pub fn bucket_start_ts(&self, idx: usize) -> i64 { + self.bucket_end_ts(idx) - self.bucket_size_secs + } + + pub fn daily(now_ts: i64, days: usize) -> Self { + let day_anchor = next_midnight_utc(now_ts); + + Self { + bucket_size_secs: 86_400, + anchor_end_ts: day_anchor, + buckets: vec![Bucket::default(); days], + } + } + + pub fn weekly(now_ts: i64, weeks: usize, week_starts_monday: bool) -> Self { + let anchor_end_ts = next_week_boundary_utc(now_ts, week_starts_monday); + Self { + bucket_size_secs: 7 * 86_400, + anchor_end_ts, + buckets: vec![Bucket::default(); weeks], + } + } + + // “month-ish” (30d buckets) but aligned so bucket 0 ends at the next month boundary + pub fn monthly_30d(now_ts: i64, months: usize) -> Self { + let anchor_end_ts = next_month_boundary_utc(now_ts); + Self { + bucket_size_secs: 30 * 86_400, + anchor_end_ts, + buckets: vec![Bucket::default(); months], + } + } + + #[inline(always)] + pub fn bump(&mut self, note: &Note<'_>) { + let ts = note.created_at() as i64; + + // bucket windows are [end-(i+1)*size, end-i*size) + // so treat `end` itself as "future" + let delta = (self.anchor_end_ts - 1) - ts; + + if delta < 0 { + return; // ignore future timestamps + } + + let idx = (delta / self.bucket_size_secs) as usize; + if idx >= self.buckets.len() { + return; // outside window + } + + self.buckets[idx].bump(note); + } +} + +#[derive(Clone, Debug, Default)] +struct DashboardState { + total: Bucket, + daily: RollingCache, + weekly: RollingCache, + monthly: RollingCache, +} + +#[derive(Debug, Clone)] +struct Snapshot { + started_at: Instant, + snapshot_at: Instant, + state: DashboardState, +} + +#[derive(Debug)] +enum WorkerMsg { + Snapshot(Snapshot), + Finished { + started_at: Instant, + finished_at: Instant, + state: DashboardState, + }, + Failed { + started_at: Instant, + finished_at: Instant, + error: String, + }, +} + +// ---------------------- +// Dashboard (single pass, single worker) +// ---------------------- + +pub struct Dashboard { + initialized: bool, + + // Worker channels + cmd_tx: Option<chan::Sender<WorkerCmd>>, + msg_rx: Option<chan::Receiver<WorkerMsg>>, + + // Refresh policy + refresh_every: Duration, + next_tick: Instant, + + // Global UI controls + period: Period, + + // UI state (progressively filled via snapshots) + running: bool, + + last_started: Option<Instant>, + last_snapshot: Option<Instant>, + last_finished: Option<Instant>, + last_duration: Option<Duration>, + last_error: Option<String>, + + state: DashboardState, +} + +impl Default for Dashboard { + fn default() -> Self { + Self { + initialized: false, + + period: Period::Weekly, + + cmd_tx: None, + msg_rx: None, + + refresh_every: Duration::from_secs(300), + next_tick: Instant::now(), + + running: false, + last_started: None, + last_snapshot: None, + last_finished: None, + last_duration: None, + last_error: None, + + state: DashboardState::default(), + } + } +} + +impl notedeck::App for Dashboard { + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { + try_process_events_core(ctx, ui.ctx(), |_, _| {}); + + if !self.initialized { + self.initialized = true; + self.init(ui.ctx().clone(), ctx); + } + + self.process_worker_msgs(); + self.schedule_refresh(); + + self.show(ui, ctx); + + AppResponse::none() + } +} + +impl Dashboard { + fn selected_cache(&self) -> &RollingCache { + match self.period { + Period::Daily => &self.state.daily, + Period::Weekly => &self.state.weekly, + Period::Monthly => &self.state.monthly, + } + } + + fn init(&mut self, egui_ctx: egui::Context, ctx: &mut AppContext<'_>) { + // spawn single worker thread and keep it alive + let (cmd_tx, cmd_rx) = chan::unbounded::<WorkerCmd>(); + let (msg_tx, msg_rx) = chan::unbounded::<WorkerMsg>(); + + self.cmd_tx = Some(cmd_tx.clone()); + self.msg_rx = Some(msg_rx); + + // Clone the DB handle into the worker thread (Ndb is typically cheap/cloneable) + let ndb = ctx.ndb.clone(); + + spawn_worker(egui_ctx, ndb, cmd_rx, msg_tx); + + // kick the first run immediately + let _ = cmd_tx.send(WorkerCmd::Refresh); + self.running = true; + self.last_error = None; + self.last_started = Some(Instant::now()); + self.last_snapshot = None; + self.last_finished = None; + self.last_duration = None; + self.state = DashboardState::default(); + } + + fn process_worker_msgs(&mut self) { + let Some(rx) = &self.msg_rx else { return }; + + let mut got_any = false; + + while let Ok(msg) = rx.try_recv() { + got_any = true; + match msg { + WorkerMsg::Snapshot(s) => { + self.running = true; + self.last_started = Some(s.started_at); + self.last_snapshot = Some(s.snapshot_at); + self.last_error = None; + + self.state = s.state; + } + WorkerMsg::Finished { + started_at, + finished_at, + state, + } => { + self.running = false; + self.last_started = Some(started_at); + self.last_snapshot = Some(finished_at); + self.last_finished = Some(finished_at); + self.last_duration = Some(finished_at.saturating_duration_since(started_at)); + self.last_error = None; + + self.state = state; + } + WorkerMsg::Failed { + started_at, + finished_at, + error, + } => { + self.running = false; + self.last_started = Some(started_at); + self.last_snapshot = Some(finished_at); + self.last_finished = Some(finished_at); + self.last_duration = Some(finished_at.saturating_duration_since(started_at)); + self.last_error = Some(error); + } + } + } + + if got_any { + // No-op; we already requested repaint on every message. + } + } + + fn schedule_refresh(&mut self) { + // throttle scheduling checks a bit + let now = Instant::now(); + if now < self.next_tick { + return; + } + self.next_tick = now + Duration::from_millis(200); + + if self.running { + return; + } + + // refresh every 30 seconds from the last finished time (or from init) + let last = self + .last_finished + .or(self.last_started) + .unwrap_or_else(Instant::now); + + if now.saturating_duration_since(last) >= self.refresh_every + && let Some(tx) = &self.cmd_tx + { + // reset UI fields for progressive load, but keep old values visible until snapshots arrive + self.running = true; + self.last_error = None; + self.last_started = Some(now); + self.last_snapshot = None; + self.last_finished = None; + self.last_duration = None; + self.state = DashboardState::default(); + + let _ = tx.send(WorkerCmd::Refresh); + } + } + + fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + crate::ui::dashboard_ui(self, ui, ctx); + } +} + +// ---------------------- +// Worker side (single pass, periodic snapshots) +// ---------------------- + +fn spawn_worker( + ctx: egui::Context, + ndb: Ndb, + cmd_rx: chan::Receiver<WorkerCmd>, + msg_tx: chan::Sender<WorkerMsg>, +) { + thread::Builder::new() + .name("dashboard-worker".to_owned()) + .spawn(move || { + let mut should_quit = false; + + while !should_quit { + match cmd_rx.recv() { + Ok(WorkerCmd::Refresh) => { + let started_at = Instant::now(); + + match materialize_single_pass(&ctx, &ndb, &msg_tx, started_at) { + Ok(state) => { + let _ = msg_tx.send(WorkerMsg::Finished { + started_at, + finished_at: Instant::now(), + state, + }); + } + Err(e) => { + let _ = msg_tx.send(WorkerMsg::Failed { + started_at, + finished_at: Instant::now(), + error: format!("{e:?}"), + }); + } + } + } + Err(_) => { + should_quit = true; + } + } + } + }) + .expect("failed to spawn dashboard worker thread"); +} + +struct Acc { + last_emit: Instant, + + state: DashboardState, +} + +fn materialize_single_pass( + ctx: &egui::Context, + ndb: &Ndb, + msg_tx: &chan::Sender<WorkerMsg>, + started_at: Instant, +) -> Result<DashboardState, nostrdb::Error> { + // one transaction per refresh run + let txn = Transaction::new(ndb)?; + + // all notes + let filters = vec![Filter::new_with_capacity(1).build()]; + + let days = 14; + let weeks = 12; + let months = 12; + let week_starts_monday = true; + + let now = Utc::now().timestamp(); + + let mut acc = Acc { + last_emit: Instant::now(), + state: DashboardState { + total: Bucket::default(), + daily: RollingCache::daily(now, days), + weekly: RollingCache::weekly(now, weeks, week_starts_monday), + monthly: RollingCache::monthly_30d(now, months), + }, + }; + + let emit_every = Duration::from_millis(32); + + let _ = ndb.fold(&txn, &filters, &mut acc, |acc, note| { + acc.state.total.bump(&note); + acc.state.daily.bump(&note); + acc.state.weekly.bump(&note); + acc.state.monthly.bump(&note); + + let now = Instant::now(); + if now.saturating_duration_since(acc.last_emit) >= emit_every { + acc.last_emit = now; + + let _ = msg_tx.send(WorkerMsg::Snapshot(Snapshot { + started_at, + snapshot_at: now, + state: acc.state.clone(), + })); + + ctx.request_repaint(); + } + + acc + }); + + Ok(acc.state) +} + +fn next_midnight_utc(now_ts: i64) -> i64 { + let dt = Utc.timestamp_opt(now_ts, 0).single().unwrap(); + let tomorrow = dt.date_naive().succ_opt().unwrap(); + Utc.from_utc_datetime(&tomorrow.and_hms_opt(0, 0, 0).unwrap()) + .timestamp() +} + +fn next_week_boundary_utc(now_ts: i64, starts_monday: bool) -> i64 { + let dt = Utc.timestamp_opt(now_ts, 0).single().unwrap(); + let today = dt.date_naive(); + + let start = if starts_monday { + chrono::Weekday::Mon + } else { + chrono::Weekday::Sun + }; + let weekday = today.weekday(); + + // days until next week start (0..6); if today is start, boundary is next week start (7 days) + let mut delta = + (7 + (start.num_days_from_monday() as i32) - (weekday.num_days_from_monday() as i32)) % 7; + if delta == 0 { + delta = 7; + } + + let next = today + chrono::Duration::days(delta as i64); + Utc.from_utc_datetime(&next.and_hms_opt(0, 0, 0).unwrap()) + .timestamp() +} + +fn next_month_boundary_utc(now_ts: i64) -> i64 { + let dt = Utc.timestamp_opt(now_ts, 0).single().unwrap(); + let y = dt.year(); + let m = dt.month(); + + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + Utc.with_ymd_and_hms(ny, nm, 1, 0, 0, 0) + .single() + .unwrap() + .timestamp() +} + +fn top_kinds_over(cache: &RollingCache, limit: usize) -> Vec<(u64, u64)> { + let mut agg: FxHashMap<u64, u64> = Default::default(); + + for b in &cache.buckets { + for (kind, count) in &b.kinds { + *agg.entry(*kind).or_default() += *count as u64; + } + } + + let mut v: Vec<_> = agg.into_iter().collect(); + v.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v.truncate(limit); + v +} + +pub(crate) fn top_kind1_authors_over(cache: &RollingCache, limit: usize) -> Vec<(Pubkey, u64)> { + let mut agg: FxHashMap<Pubkey, u64> = Default::default(); + for b in &cache.buckets { + for (pubkey, count) in &b.kind1_authors { + *agg.entry(*pubkey).or_default() += *count as u64; + } + } + let mut v: Vec<_> = agg.into_iter().collect(); + v.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + v.truncate(limit); + v +} diff --git a/crates/notedeck_dashboard/src/sparkline.rs b/crates/notedeck_dashboard/src/sparkline.rs @@ -0,0 +1,89 @@ +use egui::{Color32, Pos2, Response, Sense, Stroke, Ui, Vec2}; + +#[derive(Clone, Copy)] +pub struct SparkStyle { + pub stroke: Stroke, + pub fill_alpha: u8, + pub rounding: f32, +} + +impl Default for SparkStyle { + fn default() -> Self { + Self { + stroke: Stroke::new(1.5, Color32::WHITE), + fill_alpha: 40, + rounding: 3.0, + } + } +} + +/// values are samples over time (left=oldest, right=newest) +pub fn sparkline( + ui: &mut Ui, + size: Vec2, + values: &[f32], + color: Color32, + style: SparkStyle, +) -> Response { + let (rect, resp) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(rect); + + // background + //painter.rect_filled(rect, style.rounding, ui.visuals().widgets.inactive.bg_fill); + + if values.len() < 2 { + return resp; + } + + let mut min_v = f32::INFINITY; + let mut max_v = f32::NEG_INFINITY; + for &v in values { + let v = v.max(0.0); + min_v = min_v.min(v); + max_v = max_v.max(v); + } + // avoid div by zero, also allow flat lines + let span = (max_v - min_v).max(1.0); + + let n = values.len(); + let dx = rect.width() / (n.saturating_sub(1) as f32).max(1.0); + + let mut pts: Vec<Pos2> = Vec::with_capacity(n); + for (i, &v) in values.iter().enumerate() { + let t = (v.max(0.0) - min_v) / span; // 0..1 + let x = rect.left() + (i as f32) * dx; + let y = rect.bottom() - t * rect.height(); + pts.push(Pos2::new(x, y)); + } + + // fill under curve + /* + let mut fill_pts = Vec::with_capacity(pts.len() + 2); + fill_pts.extend_from_slice(&pts); + fill_pts.push(Pos2::new(rect.right(), rect.bottom())); + fill_pts.push(Pos2::new(rect.left(), rect.bottom())); + + let mut fill_color = color; + fill_color = Color32::from_rgba_premultiplied( + fill_color.r(), + fill_color.g(), + fill_color.b(), + style.fill_alpha, + ); + + painter.add(egui::Shape::Path(egui::epaint::PathShape { + points: fill_pts, + closed: true, + fill: fill_color, + stroke: Stroke::NONE.into(), + })); + */ + + // line + painter.add(egui::Shape::line( + pts, + Stroke::new(style.stroke.width, color), + )); + + resp +} diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs @@ -0,0 +1,520 @@ +use egui::FontId; +use egui::RichText; + +use std::time::Duration; +use std::time::Instant; + +use nostrdb::Transaction; +use notedeck::{abbrev::floor_char_boundary, name::get_display_name, profile::get_profile_url, AppContext}; +use notedeck_ui::ProfilePic; + +use crate::Dashboard; +use crate::FxHashMap; +use crate::Period; +use crate::RollingCache; +use crate::chart::Bar; +use crate::chart::BarChartStyle; +use crate::chart::horizontal_bar_chart; +use crate::chart::palette; +use crate::top_kind1_authors_over; +use crate::top_kinds_over; + +pub fn period_picker_ui(ui: &mut egui::Ui, period: &mut Period) { + ui.horizontal(|ui| { + for p in Period::ALL { + let selected = *period == p; + if ui.selectable_label(selected, p.label()).clicked() { + *period = p; + } + } + }); +} + +pub fn dashboard_controls_ui(d: &mut Dashboard, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Range").small().weak()); + period_picker_ui(ui, &mut d.period); + + ui.add_space(12.0); + }); +} + +pub fn footer_status_ui( + ui: &mut egui::Ui, + running: bool, + err: Option<&str>, + last_snapshot: Option<Instant>, + last_duration: Option<Duration>, +) { + ui.add_space(8.0); + + if let Some(e) = err { + ui.label(RichText::new(e).color(ui.visuals().error_fg_color).small()); + return; + } + + let mut parts: Vec<String> = Vec::new(); + if running { + parts.push("updating…".to_owned()); + } + + if let Some(t) = last_snapshot { + parts.push(format!( + "updated {:.1?} ago", + Instant::now().duration_since(t) + )); + } + + if let Some(d) = last_duration { + let ms = d.as_secs_f64() * 1000.0; + parts.push(format!("{ms:.0} ms")); + } + + if parts.is_empty() { + parts.push("—".to_owned()); + } + + ui.label(RichText::new(parts.join(" · ")).small().weak()); +} + +fn card_header_ui(ui: &mut egui::Ui, title: &str) { + ui.horizontal(|ui| { + let weak = ui.visuals().weak_text_color(); + ui.add( + egui::Label::new(egui::RichText::new(title).small().color(weak)) + .wrap_mode(egui::TextWrapMode::Wrap), + ); + }); +} + +pub fn card_ui( + ui: &mut egui::Ui, + min_card: f32, + content: impl FnOnce(&mut egui::Ui), +) -> egui::Response { + let visuals = ui.visuals().clone(); + egui::Frame::group(ui.style()) + .fill(visuals.extreme_bg_color) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(12)) + .stroke(egui::Stroke::new( + 1.0, + visuals.widgets.noninteractive.bg_stroke.color, + )) + .show(ui, |ui| { + ui.set_min_width(min_card); + ui.set_min_height(min_card * 0.5); + ui.vertical(|ui| { + content(ui); + }); + }) + .response +} + +pub fn kinds_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "Kinds"); + ui.add_space(8.0); + + // top kind limit, don't show more then this + let limit = 10; + + let window_total = match dashboard.period { + Period::Daily => total_over(&dashboard.state.daily), + Period::Weekly => total_over(&dashboard.state.weekly), + Period::Monthly => total_over(&dashboard.state.monthly), + }; + + let top = match dashboard.period { + Period::Daily => top_kinds_over(&dashboard.state.daily, limit), + Period::Weekly => top_kinds_over(&dashboard.state.weekly, limit), + Period::Monthly => top_kinds_over(&dashboard.state.monthly, limit), + }; + + let bars = kinds_to_bars(&top); + + if bars.is_empty() && window_total == 0 && dashboard.last_error.is_none() { + // still show something (no loading screen) + ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); + } else { + horizontal_bar_chart(ui, None, &bars, BarChartStyle::default()); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} + +pub fn totals_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "All notes"); + ui.add_space(8.0); + + let count: u64 = match dashboard.period { + Period::Daily => total_over(&dashboard.state.daily), + Period::Weekly => total_over(&dashboard.state.weekly), + Period::Monthly => total_over(&dashboard.state.monthly), + }; + + ui.horizontal(|ui| { + ui.label( + RichText::new(count.to_string()) + .font(FontId::proportional(34.0)) + .strong(), + ); + + ui.add_space(10.0); + }); +} + +pub fn posts_per_period_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { + card_header_ui( + ui, + &format!("Kind 1 posts per {}", dashboard.period.label()), + ); + ui.add_space(8.0); + + let cache = dashboard.selected_cache(); + let bars = series_bars_for_kind(dashboard.period, cache, 1); + + if bars.is_empty() && dashboard.state.total.total == 0 && dashboard.last_error.is_none() { + ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); + } else if bars.is_empty() { + ui.label("No data"); + } else { + horizontal_bar_chart(ui, None, &bars, BarChartStyle::default()); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} + +fn kinds_to_bars(top_kinds: &[(u64, u64)]) -> Vec<Bar> { + top_kinds + .iter() + .enumerate() + .map(|(i, (k, c))| Bar { + label: format!("{k}"), + value: *c as f32, + color: palette(i), + }) + .collect() +} + +fn month_label(year: i32, month: u32) -> String { + // e.g. "Jan ’26" when year differs, otherwise just "Jan" would be + // ambiguous across years We'll always include the year suffix to + // keep it clear when the range crosses years. + const NAMES: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + let name = NAMES[(month.saturating_sub(1)) as usize]; + let yy = (year % 100).abs(); + format!("{name} \u{2019}{yy:02}") +} + +fn bucket_label(period: Period, start_ts: i64, end_ts: i64) -> String { + use chrono::{Datelike, TimeZone, Utc}; + + // end-1 keeps labels stable at boundaries + let default_label = "—"; + let Some(end_dt) = Utc.timestamp_opt(end_ts.saturating_sub(1), 0).single() else { + return default_label.to_owned(); + }; + + match period { + Period::Daily => end_dt.format("%b %d").to_string(), + Period::Weekly => match Utc.timestamp_opt(start_ts, 0).single() { + Some(s) => format!("{}-{}", s.format("%b %d"), end_dt.format("%b %d")), + None => default_label.to_owned(), + }, + Period::Monthly => month_label(end_dt.year(), end_dt.month()), + } +} + +fn series_bars_for_kind(period: Period, cache: &RollingCache, kind: u64) -> Vec<Bar> { + let n = cache.buckets.len(); + let mut out = Vec::with_capacity(n); + + for i in (0..n).rev() { + let end_ts = cache.bucket_end_ts(i); + let start_ts = cache.bucket_start_ts(i); + + let label = bucket_label(period, start_ts, end_ts); + + let count = *cache.buckets[i].kinds.get(&kind).unwrap_or(&0) as f32; + + out.push(Bar { + label, + value: count, + color: palette(out.len()), + }); + } + + out +} + +// Count totals +fn total_over(cache: &RollingCache) -> u64 { + cache.buckets.iter().map(|b| b.total).sum() +} + +pub fn dashboard_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + egui::Frame::new() + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + dashboard_ui_inner(dashboard, ui, ctx); + }); + }); +} + +fn dashboard_ui_inner(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + let min_card = 240.0; + let gap = 8.0; + + dashboard_controls_ui(dashboard, ui); + + ui.with_layout( + egui::Layout::left_to_right(egui::Align::TOP).with_main_wrap(true), + |ui| { + ui.spacing_mut().item_spacing = egui::vec2(gap, gap); + let size = [min_card, min_card]; + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| totals_ui(dashboard, ui)) + }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| posts_per_period_ui(dashboard, ui)) + }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| kinds_ui(dashboard, ui)) + }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| clients_stack_ui(dashboard, ui)) + }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| clients_trends_ui(dashboard, ui)) + }); + ui.add_sized(size, |ui: &mut egui::Ui| { + card_ui(ui, min_card, |ui| top_posters_ui(dashboard, ui, ctx)) + }); + }, + ); +} + +fn client_series(cache: &RollingCache, client: &str) -> Vec<f32> { + // left=oldest, right=newest like your series_bars_for_kind does + let n = cache.buckets.len(); + let mut out = Vec::with_capacity(n); + for i in (0..n).rev() { + let v = *cache.buckets[i].clients.get(client).unwrap_or(&0) as f32; + out.push(v); + } + out +} + +pub fn clients_trends_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "Clients (trend)"); + ui.add_space(8.0); + + let limit = 10; + + let cache = match dashboard.period { + Period::Daily => &dashboard.state.daily, + Period::Weekly => &dashboard.state.weekly, + Period::Monthly => &dashboard.state.monthly, + }; + + let top = top_clients_over(cache, limit); // your existing “top N” is fine as a selector + if top.is_empty() && dashboard.last_error.is_none() { + ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); + return; + } + if top.is_empty() { + ui.label("No client tags"); + return; + } + + let spark_w = (ui.available_width() - 140.0).max(80.0); + let spark_h = 18.0; + + for (row_i, (client, total)) in top.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(RichText::new(client).small()); + ui.add_space(6.0); + + let series = client_series(cache, client); + + let resp = crate::sparkline::sparkline( + ui, + egui::vec2(spark_w, spark_h), + &series, + palette(row_i), + crate::sparkline::SparkStyle::default(), + ); + + // tooltip: last bucket + total + if resp.hovered() { + let last = series.last().copied().unwrap_or(0.0); + resp.on_hover_text(format!("total: {total}\nlatest bucket: {:.0}", last)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(total.to_string()).small().strong()); + }); + }); + ui.add_space(4.0); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} + +fn stacked_clients_over_time( + cache: &RollingCache, + top: &[(String, u64)], +) -> Vec<Vec<(egui::Color32, f32)>> { + let n = cache.buckets.len(); + let mut out = Vec::with_capacity(n); + + // oldest -> newest + for i in (0..n).rev() { + let mut segs = Vec::with_capacity(top.len()); + for (idx, (name, _)) in top.iter().enumerate() { + let v = *cache.buckets[i].clients.get(name).unwrap_or(&0) as f32; + segs.push((palette(idx), v)); + } + out.push(segs); + } + out +} + +pub fn clients_stack_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "Clients (stacked over time)"); + ui.add_space(8.0); + + let limit = 6; // stacked charts get noisy fast; 5–7 is usually sweet spot + + let cache = dashboard.selected_cache(); + let top = top_clients_over(cache, limit); + + if top.is_empty() && dashboard.last_error.is_none() { + ui.label(RichText::new("…").font(FontId::proportional(24.0)).weak()); + } else if top.is_empty() { + ui.label("No client tags"); + } else { + let buckets = stacked_clients_over_time(cache, &top); + let w = ui.available_width().max(120.0); + let h = 70.0; + + let resp = crate::chart::stacked_bars(ui, egui::vec2(w, h), &buckets); + + // legend + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + for (i, (name, _)) in top.iter().enumerate() { + ui.label(RichText::new("■").color(palette(i))); + ui.label(RichText::new(name).small()); + ui.add_space(10.0); + } + }); + + // you can also attach hover-to-bucket tooltip later if you want (based on pointer x -> bucket index) + let _ = resp; + } +} + +fn top_clients_over(cache: &RollingCache, limit: usize) -> Vec<(String, u64)> { + let mut agg: FxHashMap<String, u64> = FxHashMap::default(); + + for b in &cache.buckets { + for (client, count) in &b.clients { + *agg.entry(client.clone()).or_default() += *count as u64; + } + } + + let mut out: Vec<(String, u64)> = agg.into_iter().collect(); + + // sort desc by count; tie-break by name for stability + out.sort_by(|(a_name, a_cnt), (b_name, b_cnt)| { + b_cnt.cmp(a_cnt).then_with(|| a_name.cmp(b_name)) + }); + + out.truncate(limit); + out +} + +pub fn top_posters_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui, ctx: &mut AppContext<'_>) { + let cache = dashboard.selected_cache(); + let n = cache.buckets.len(); + let unit = dashboard.period.label(); + let header = format!("Top Posters ({n} {unit}s)"); + card_header_ui(ui, &header); + ui.add_space(8.0); + + let limit = 10; + let top = top_kind1_authors_over(cache, limit); + + if top.is_empty() && dashboard.last_error.is_none() { + ui.label(RichText::new("...").font(FontId::proportional(24.0)).weak()); + return; + } + + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(_) => { + ui.label("DB error"); + return; + } + }; + + let pfp_size = ProfilePic::small_size() as f32; + + for (pubkey, count) in &top { + let profile = ctx.ndb.get_profile_by_pubkey(&txn, pubkey.bytes()).ok(); + let name = get_display_name(profile.as_ref()); + let pfp_url = get_profile_url(profile.as_ref()); + + ui.horizontal(|ui| { + ui.add( + &mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), pfp_url) + .size(pfp_size), + ); + ui.add_space(6.0); + + let display = name.name(); + let truncated = if display.len() > 16 { + let end = floor_char_boundary(display, 16); + format!("{}...", &display[..end]) + } else { + display.to_string() + }; + ui.label(RichText::new(truncated).small()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(count.to_string()).small().strong()); + }); + }); + ui.add_space(4.0); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} diff --git a/shell.nix b/shell.nix @@ -14,7 +14,6 @@ mkShell ({ #cargo-edit #cargo-watch rustup - sccache gdb libiconv pkg-config @@ -43,7 +42,6 @@ mkShell ({ } // ( lib.optionalAttrs (!stdenv.isDarwin) { - RUSTC_WRAPPER="${sccache}/bin/sccache"; LD_LIBRARY_PATH = "${x11libs}"; #XDG_DATA_DIRS = "${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}"; }