commit d7551645d7f46e0e4d507b2cb6cae0fc19ee3022
parent 04dd8adfaef8f2b778626fec0e3fe2ac9d3b189c
Author: William Casarin <jb55@jb55.com>
Date: Wed, 4 Feb 2026 18:03:46 -0800
Merge dashboard app
Diffstat:
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(¬e);
+ acc.state.daily.bump(¬e);
+ acc.state.weekly.bump(¬e);
+ acc.state.monthly.bump(¬e);
+
+ 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}";
}