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:
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);
+ });
+ });
}