notedeck

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

commit 698200727abb64367ade09bc4fe4f8fb656b37b6
parent a4f9f9314e269209d7ede5c223577322451a04ae
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 20 Jan 2026 12:10:23 -0800

dashboard: add rolling range buckets for daily/weekly/monthly reports

- Track totals and kinds in fixed-size time buckets
- Add range picker to switch day/week/month views
- Render kind-1 series per selected period
- Use rustc-hash for faster hash maps

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_dashboard/Cargo.toml | 2++
Mcrates/notedeck_dashboard/src/lib.rs | 333++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_dashboard/src/ui.rs | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
4 files changed, 356 insertions(+), 156 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4044,6 +4044,7 @@ dependencies = [ "nostrdb", "notedeck", "notedeck_ui", + "rustc-hash 2.1.1", "tokio", "tracing", ] diff --git a/crates/notedeck_dashboard/Cargo.toml b/crates/notedeck_dashboard/Cargo.toml @@ -4,6 +4,8 @@ edition = "2024" version.workspace = true [dependencies] +rustc-hash = "2.1.1" + egui = { workspace = true } nostrdb = { workspace = true } chrono = { workspace = true } diff --git a/crates/notedeck_dashboard/src/lib.rs b/crates/notedeck_dashboard/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use rustc_hash::FxHashMap; use std::thread; use std::time::{Duration, Instant}; @@ -22,11 +22,112 @@ enum WorkerCmd { //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, u64>, +} + +impl Bucket { + #[inline(always)] + pub fn bump(&mut self, kind: u64) { + self.total += 1; + *self.kinds.entry(kind).or_default() += 1; + } +} + +// 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, ts: i64, kind: u64) { + // 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(kind); + } +} + #[derive(Clone, Debug, Default)] struct DashboardState { - total_count: usize, - top_kinds: Vec<(u32, u64)>, - posts_per_month: Vec<(String, u64)>, + total: Bucket, + daily: RollingCache, + weekly: RollingCache, + monthly: RollingCache, } #[derive(Debug, Clone)] @@ -66,8 +167,12 @@ pub struct Dashboard { 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>, @@ -82,10 +187,12 @@ impl Default for Dashboard { Self { initialized: false, + period: Period::Weekly, + cmd_tx: None, msg_rx: None, - refresh_every: Duration::from_secs(10), + refresh_every: Duration::from_secs(300), next_tick: Instant::now(), running: false, @@ -119,6 +226,14 @@ impl notedeck::App for Dashboard { } 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>(); @@ -228,43 +343,7 @@ impl Dashboard { } fn show(&mut self, ui: &mut egui::Ui) { - egui::Frame::new() - .inner_margin(egui::Margin::same(20)) - .show(ui, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - self.grid(ui); - }); - }); - } - - fn grid(&mut self, ui: &mut egui::Ui) { - let cols = 3; - let min_card = 240.0; - - egui::Grid::new("dashboard_grid_single_worker") - .num_columns(cols) - .min_col_width(min_card) - .spacing(egui::vec2(8.0, 8.0)) - .show(ui, |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); - }); - - ui.end_row(); - }); + crate::ui::dashboard_ui(self, ui); } } @@ -272,48 +351,6 @@ 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, @@ -357,12 +394,9 @@ fn spawn_worker( } 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, + + state: DashboardState, } fn materialize_single_pass( @@ -377,81 +411,106 @@ fn materialize_single_pass( // all notes let filters = vec![Filter::new_with_capacity(1).build()]; - 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 days = 14; + let weeks = 12; + let months = 12; + let week_starts_monday = true; + + let now = Utc::now().timestamp(); let mut acc = Acc { - total_count: 0, - kinds: HashMap::new(), last_emit: Instant::now(), - per_month: HashMap::new(), - month_keys, - cutoff_ts, + 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.total_count += 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 kind = note.kind() as u64; + + acc.state.total.bump(kind); + acc.state.daily.bump(ts, kind); + acc.state.weekly.bump(ts, kind); + acc.state.monthly.bump(ts, kind); 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, - }, + state: acc.state.clone(), })); + ctx.request_repaint(); } acc }); - Ok(DashboardState { - total_count: acc.total_count, - top_kinds: top_kinds(&acc.kinds, 6), - posts_per_month: materialize_posts_per_month(&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 top_kinds(hmap: &HashMap<u32, u64>, limit: usize) -> Vec<(u32, u64)> { - let mut v: Vec<(u32, u64)> = hmap.iter().map(|(k, c)| (*k, *c)).collect(); +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; + } + } + + 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 } - -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 @@ -5,10 +5,33 @@ use std::time::Duration; use std::time::Instant; use crate::Dashboard; +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_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, @@ -76,12 +99,28 @@ pub fn card_ui(ui: &mut egui::Ui, min_card: f32, content: impl FnOnce(&mut egui: }); } -pub fn kinds_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { +pub fn kinds_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { card_header_ui(ui, "Kinds"); ui.add_space(8.0); - let bars = kinds_to_bars(&dashboard.state.top_kinds); - if bars.is_empty() && dashboard.state.total_count == 0 && dashboard.last_error.is_none() { + // 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 { @@ -101,9 +140,15 @@ 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(dashboard.state.total_count.to_string()) + RichText::new(count.to_string()) .font(FontId::proportional(34.0)) .strong(), ); @@ -112,12 +157,17 @@ 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)"); +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 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() { + 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"); @@ -137,7 +187,7 @@ pub fn posts_per_month_ui(dashboard: &Dashboard, ui: &mut egui::Ui) { ); } -fn kinds_to_bars(top_kinds: &[(u32, u64)]) -> Vec<Bar> { +fn kinds_to_bars(top_kinds: &[(u64, u64)]) -> Vec<Bar> { top_kinds .iter() .enumerate() @@ -149,14 +199,102 @@ 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() +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() +} + +fn grid_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { + let cols = 3; + let min_card = 240.0; + + egui::Grid::new("dashboard_grid_single_worker") + .num_columns(cols) + .min_col_width(min_card) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + use crate::ui::{card_ui, kinds_ui, posts_per_period_ui, totals_ui}; + + // Card 1: Total notes + card_ui(ui, min_card, |ui| { + totals_ui(dashboard, ui); + }); + + // Card 3: Posts per period + card_ui(ui, min_card, |ui| { + posts_per_period_ui(dashboard, ui); + }); + + // Card 2: Kinds (top) + card_ui(ui, min_card, |ui| { + kinds_ui(dashboard, ui); + }); + + ui.end_row(); + }); +} + +pub fn dashboard_ui(dashboard: &mut Dashboard, ui: &mut egui::Ui) { + egui::Frame::new() + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + dashboard_controls_ui(dashboard, ui); + ui.add_space(10.0); + grid_ui(dashboard, ui); + }); + }); }