notedeck

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

commit a4f9f9314e269209d7ede5c223577322451a04ae
parent 44c7dac1eefa8a2f0316d3200f19648db00b0ea7
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 16 Jan 2026 14:58:10 -0800

dashboard: monthly kind1 post report

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_dashboard/Cargo.toml | 1+
Mcrates/notedeck_dashboard/src/lib.rs | 105++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_dashboard/src/ui.rs | 37+++++++++++++++++++++++++++++++++++++
4 files changed, 135 insertions(+), 9 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4038,6 +4038,7 @@ dependencies = [ name = "notedeck_dashboard" version = "0.7.1" dependencies = [ + "chrono", "crossbeam-channel", "egui", "nostrdb", diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml @@ -6,6 +6,7 @@ version.workspace = true [dependencies] egui = { workspace = true } nostrdb = { workspace = true } +chrono = { workspace = true } crossbeam-channel = { workspace = true } notedeck = { workspace = true } notedeck_ui = { workspace = true } diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs @@ -7,6 +7,8 @@ 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 ui; @@ -24,6 +26,7 @@ enum WorkerCmd { struct DashboardState { total_count: usize, top_kinds: Vec<(u32, u64)>, + posts_per_month: Vec<(String, u64)>, } #[derive(Debug, Clone)] @@ -235,7 +238,7 @@ impl Dashboard { } fn grid(&mut self, ui: &mut egui::Ui) { - let cols = 2; + let cols = 3; let min_card = 240.0; egui::Grid::new("dashboard_grid_single_worker") @@ -243,13 +246,18 @@ impl Dashboard { .min_col_width(min_card) .spacing(egui::vec2(8.0, 8.0)) .show(ui, |ui| { - use crate::ui::{card_ui, kinds_ui, totals_ui}; + use crate::ui::{card_ui, kinds_ui, posts_per_month_ui, totals_ui}; // Card 1: Total notes card_ui(ui, min_card, |ui| { totals_ui(self, ui); }); + // Card 3: Posts per month (last 6 months) + card_ui(ui, min_card, |ui| { + posts_per_month_ui(self, ui); + }); + // Card 2: Kinds (top) card_ui(ui, min_card, |ui| { kinds_ui(self, ui); @@ -264,6 +272,48 @@ impl Dashboard { // Worker side (single pass, periodic snapshots) // ---------------------- +fn last_n_months_keys(n: usize) -> Vec<(i32, u32)> { + // oldest -> newest, includes current month + let now = Utc::now(); + let mut y = now.year(); + let mut m = now.month(); // 1..=12 + + // go back (n-1) months to get the oldest month + for _ in 0..(n.saturating_sub(1)) { + if m == 1 { + m = 12; + y -= 1; + } else { + m -= 1; + } + } + + let mut out = Vec::with_capacity(n); + let mut cy = y; + let mut cm = m; + for _ in 0..n { + out.push((cy, cm)); + if cm == 12 { + cm = 1; + cy += 1; + } else { + cm += 1; + } + } + out +} + +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 spawn_worker( ctx: egui::Context, ndb: Ndb, @@ -306,6 +356,15 @@ fn spawn_worker( .expect("failed to spawn dashboard worker thread"); } +struct Acc { + total_count: usize, + kinds: HashMap<u32, u64>, + per_month: HashMap<(i32, u32), u64>, + month_keys: Vec<(i32, u32)>, + cutoff_ts: i64, + last_emit: Instant, +} + fn materialize_single_pass( ctx: &egui::Context, ndb: &Ndb, @@ -318,36 +377,56 @@ fn materialize_single_pass( // all notes let filters = vec![Filter::new_with_capacity(1).build()]; - struct Acc { - total_count: usize, - kinds: HashMap<u32, u64>, - last_emit: Instant, - } + let month_keys = last_n_months_keys(6); + let (cut_y, cut_m) = month_keys.first().copied().unwrap(); + let cutoff_ts = Utc + .with_ymd_and_hms(cut_y, cut_m, 1, 0, 0, 0) + .single() + .unwrap() + .timestamp(); let mut acc = Acc { total_count: 0, kinds: HashMap::new(), last_emit: Instant::now(), + per_month: HashMap::new(), + month_keys, + cutoff_ts, }; let emit_every = Duration::from_millis(32); let _ = ndb.fold(&txn, &filters, &mut acc, |acc, note| { acc.total_count += 1; - - *acc.kinds.entry(note.kind()).or_default() += 1; + let kind = note.kind(); + *acc.kinds.entry(kind).or_default() += 1; + + // kind1 posts per month (last 6 months) + let ts = note.created_at() as i64; + if kind == 1 && ts >= acc.cutoff_ts { + let dt = Utc.timestamp_opt(ts, 0).single(); + if let Some(dt) = dt { + let key = (dt.year(), dt.month()); + // only count if it’s in our 6-month window keys (avoids future or odd dates) + if acc.month_keys.iter().any(|k| *k == key) { + *acc.per_month.entry(key).or_default() += 1; + } + } + } let now = Instant::now(); if now.saturating_duration_since(acc.last_emit) >= emit_every { acc.last_emit = now; let top = top_kinds(&acc.kinds, 6); + let posts_per_month = materialize_posts_per_month(&acc); let _ = msg_tx.send(WorkerMsg::Snapshot(Snapshot { started_at, snapshot_at: now, state: DashboardState { total_count: acc.total_count, top_kinds: top, + posts_per_month, }, })); ctx.request_repaint(); @@ -359,6 +438,7 @@ fn materialize_single_pass( Ok(DashboardState { total_count: acc.total_count, top_kinds: top_kinds(&acc.kinds, 6), + posts_per_month: materialize_posts_per_month(&acc), }) } @@ -368,3 +448,10 @@ fn top_kinds(hmap: &HashMap<u32, u64>, limit: usize) -> Vec<(u32, u64)> { v.truncate(limit); v } + +fn materialize_posts_per_month(acc: &Acc) -> Vec<(String, u64)> { + acc.month_keys + .iter() + .map(|&(y, m)| (month_label(y, m), *acc.per_month.get(&(y, m)).unwrap_or(&0))) + .collect::<Vec<_>>() +} diff --git a/crates/notedeck_dashboard/src/ui.rs b/crates/notedeck_dashboard/src/ui.rs @@ -112,6 +112,31 @@ pub fn totals_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { }); } +pub fn posts_per_month_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { + card_header_ui(ui, "Posts per month (last 6 months)"); + ui.add_space(8.0); + + let bars = posts_per_month_to_bars(&dashboard.state.posts_per_month); + if bars.is_empty() && dashboard.state.total_count == 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 { + let mut style = BarChartStyle::default(); + style.value_precision = 0; + style.show_values = true; + horizontal_bar_chart(ui, None, &bars, style); + } + + footer_status_ui( + ui, + dashboard.running, + dashboard.last_error.as_deref(), + dashboard.last_snapshot, + dashboard.last_duration, + ); +} + fn kinds_to_bars(top_kinds: &[(u32, u64)]) -> Vec<Bar> { top_kinds .iter() @@ -123,3 +148,15 @@ fn kinds_to_bars(top_kinds: &[(u32, u64)]) -> Vec<Bar> { }) .collect() } + +fn posts_per_month_to_bars(items: &[(String, u64)]) -> Vec<Bar> { + items + .iter() + .enumerate() + .map(|(i, (label, count))| Bar { + label: label.clone(), + value: *count as f32, + color: palette(i), + }) + .collect() +}