From 5a251a1cbec7c62f1337aaf9d1bc1582d3191ec0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 2 Apr 2026 16:38:35 -0700 Subject: [PATCH] feat: add Axis::None for hidden dimensions with implicit aggregation Categories on the None axis are excluded from the grid and cell keys. When evaluating cells, values across hidden dimensions are aggregated using a per-measure function (default SUM). Adds evaluate_aggregated to Model, none_cats to GridLayout, and 'n' shortcut in TileSelect. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/types.rs | 146 ++++++++++++++++++++++++++++++++++++++- src/persistence/mod.rs | 2 + src/ui/app.rs | 20 +++++- src/ui/category_panel.rs | 1 + src/ui/grid.rs | 4 +- src/ui/tile_bar.rs | 1 + src/view/axis.rs | 2 + src/view/layout.rs | 8 +++ src/view/types.rs | 21 ++++-- 9 files changed, 193 insertions(+), 12 deletions(-) diff --git a/src/model/types.rs b/src/model/types.rs index 6a80c52..29aee91 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -1,10 +1,12 @@ +use std::collections::HashMap; + use anyhow::{anyhow, Result}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use super::category::{Category, CategoryId}; use super::cell::{CellKey, CellValue, DataStore}; -use crate::formula::Formula; +use crate::formula::{AggFunc, Formula}; use crate::view::View; const MAX_CATEGORIES: usize = 12; @@ -18,6 +20,10 @@ pub struct Model { pub views: IndexMap, pub active_view: String, next_category_id: CategoryId, + /// Per-measure aggregation function (measure item name → agg func). + /// Used when collapsing categories on `Axis::None`. Defaults to SUM. + #[serde(default)] + pub measure_agg: HashMap, } impl Model { @@ -34,6 +40,7 @@ impl Model { views, active_view: "Default".to_string(), next_category_id: 0, + measure_agg: HashMap::new(), } } @@ -172,6 +179,67 @@ impl Model { self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0) } + /// Evaluate a cell, aggregating over any hidden (None-axis) categories. + /// When `none_cats` is empty, delegates to `evaluate`. + /// Otherwise, uses `matching_cells` with the partial key and aggregates + /// using the measure's agg function (default SUM). + pub fn evaluate_aggregated( + &self, + key: &CellKey, + none_cats: &[String], + ) -> Option { + if none_cats.is_empty() { + return self.evaluate(key); + } + + // Check formulas first — they handle their own aggregation + for formula in &self.formulas { + if let Some(item_val) = key.get(&formula.target_category) { + if item_val == formula.target { + return self.eval_formula(formula, key); + } + } + } + + // Aggregate raw data across all None-axis categories + let values: Vec = self + .data + .matching_cells(&key.0) + .into_iter() + .filter_map(|(_, v)| v.as_f64()) + .collect(); + + if values.is_empty() { + return None; + } + + // Determine agg func from measure_agg map, defaulting to SUM + let agg = key + .get("Measure") + .and_then(|m| self.measure_agg.get(m)) + .unwrap_or(&AggFunc::Sum); + + let result = match agg { + AggFunc::Sum => values.iter().sum(), + AggFunc::Avg => values.iter().sum::() / values.len() as f64, + AggFunc::Min => values.iter().cloned().reduce(f64::min)?, + AggFunc::Max => values.iter().cloned().reduce(f64::max)?, + AggFunc::Count => values.len() as f64, + }; + Some(CellValue::Number(result)) + } + + /// Evaluate aggregated as f64, returning 0.0 for empty cells. + pub fn evaluate_aggregated_f64( + &self, + key: &CellKey, + none_cats: &[String], + ) -> f64 { + self.evaluate_aggregated(key, none_cats) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + } + fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option { use crate::formula::{AggFunc, Expr}; @@ -490,6 +558,82 @@ mod model_tests { m.set_cell(k.clone(), CellValue::Number(77.0)); assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0))); } + + #[test] + fn evaluate_aggregated_sums_over_hidden_dimension() { + let mut m = Model::new("Test"); + m.add_category("Payee").unwrap(); + m.add_category("Date").unwrap(); + m.add_category("Measure").unwrap(); + m.category_mut("Payee").unwrap().add_item("Acme"); + m.category_mut("Date").unwrap().add_item("Jan-01"); + m.category_mut("Date").unwrap().add_item("Jan-02"); + m.category_mut("Measure").unwrap().add_item("Amount"); + + m.set_cell( + coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]), + CellValue::Number(100.0), + ); + m.set_cell( + coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]), + CellValue::Number(50.0), + ); + + // Without hidden dims, returns None for partial key + let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]); + assert_eq!(m.evaluate(&partial_key), None); + + // With Date as hidden dimension, aggregates to SUM + let none_cats = vec!["Date".to_string()]; + let result = m.evaluate_aggregated(&partial_key, &none_cats); + assert_eq!(result, Some(CellValue::Number(150.0))); + } + + #[test] + fn evaluate_aggregated_no_hidden_delegates_to_evaluate() { + let mut m = Model::new("Test"); + m.add_category("Region").unwrap(); + m.category_mut("Region").unwrap().add_item("East"); + m.set_cell( + coord(&[("Region", "East")]), + CellValue::Number(42.0), + ); + let key = coord(&[("Region", "East")]); + assert_eq!( + m.evaluate_aggregated(&key, &[]), + Some(CellValue::Number(42.0)) + ); + } + + #[test] + fn evaluate_aggregated_respects_measure_agg() { + use crate::formula::AggFunc; + + let mut m = Model::new("Test"); + m.add_category("Payee").unwrap(); + m.add_category("Date").unwrap(); + m.add_category("Measure").unwrap(); + m.category_mut("Payee").unwrap().add_item("Acme"); + m.category_mut("Date").unwrap().add_item("D1"); + m.category_mut("Date").unwrap().add_item("D2"); + m.category_mut("Measure").unwrap().add_item("Price"); + + m.set_cell( + coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]), + CellValue::Number(10.0), + ); + m.set_cell( + coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]), + CellValue::Number(30.0), + ); + + m.measure_agg.insert("Price".to_string(), AggFunc::Avg); + + let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]); + let none_cats = vec!["Date".to_string()]; + let result = m.evaluate_aggregated(&key, &none_cats); + assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20 + } } #[cfg(test)] diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 96d9f42..26acc62 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -117,6 +117,7 @@ pub fn format_md(model: &Model) -> String { Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(), None => writeln!(out, "{}: page", cat).unwrap(), }, + Axis::None => writeln!(out, "{}: none", cat).unwrap(), } } if !view.number_format.is_empty() { @@ -315,6 +316,7 @@ pub fn parse_md(text: &str) -> Result { let axis = match rest { "row" => Axis::Row, "column" => Axis::Column, + "none" => Axis::None, _ => continue, }; view.axes.push((cat.to_string(), axis)); diff --git a/src/ui/app.rs b/src/ui/app.rs index b5fe2ef..1ac7e4d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -402,7 +402,8 @@ impl App { // yy = yank current cell ('y', KeyCode::Char('y')) => { if let Some(key) = self.selected_cell_key() { - self.yanked = self.model.evaluate(&key); + let layout = GridLayout::new(&self.model, self.model.active_view()); + self.yanked = self.model.evaluate_aggregated(&key, &layout.none_cats); self.status_msg = "Yanked".to_string(); } } @@ -1111,6 +1112,19 @@ impl App { } self.mode = AppMode::Normal; } + KeyCode::Char('n') => { + if let Some(name) = cat_names.get(cat_idx) { + command::dispatch( + &mut self.model, + &Command::SetAxis { + category: name.clone(), + axis: Axis::None, + }, + ); + self.dirty = true; + } + self.mode = AppMode::Normal; + } _ => {} } Ok(()) @@ -1386,7 +1400,7 @@ impl App { Some(k) => k, None => return false, }; - let s = match self.model.evaluate(&key) { + let s = match self.model.evaluate_aggregated(&key, &layout.none_cats) { Some(CellValue::Number(n)) => format!("{n}"), Some(CellValue::Text(t)) => t, None => String::new(), @@ -1608,7 +1622,7 @@ impl App { AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name", AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name", AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back", - AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back", + AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back", AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help", AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel", _ => "", diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index ba8afe0..d60d60f 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -14,6 +14,7 @@ fn axis_display(axis: Axis) -> (&'static str, Color) { Axis::Row => ("Row ↕", Color::Green), Axis::Column => ("Col ↔", Color::Blue), Axis::Page => ("Page ☰", Color::Magenta), + Axis::None => ("None ∅", Color::DarkGray), } } diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 25ee1e3..aa8395f 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -291,7 +291,7 @@ impl<'a> GridWidget<'a> { continue; } }; - let value = self.model.evaluate(&key); + let value = self.model.evaluate_aggregated(&key, &layout.none_cats); let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals); let is_selected = ri == sel_row && ci == sel_col; @@ -378,7 +378,7 @@ impl<'a> GridWidget<'a> { } let total: f64 = (0..layout.row_count()) .filter_map(|ri| layout.cell_key(ri, ci)) - .map(|key| self.model.evaluate_f64(&key)) + .map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats)) .sum(); let total_str = format_f64(total, fmt_comma, fmt_decimals); buf.set_string( diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 9c4bd96..12d58b5 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -14,6 +14,7 @@ fn axis_display(axis: Axis) -> (&'static str, Color) { Axis::Row => ("↕", Color::Green), Axis::Column => ("↔", Color::Blue), Axis::Page => ("☰", Color::Magenta), + Axis::None => ("∅", Color::DarkGray), } } diff --git a/src/view/axis.rs b/src/view/axis.rs index 03c6e17..28f415b 100644 --- a/src/view/axis.rs +++ b/src/view/axis.rs @@ -6,6 +6,7 @@ pub enum Axis { Row, Column, Page, + None, } impl std::fmt::Display for Axis { @@ -14,6 +15,7 @@ impl std::fmt::Display for Axis { Axis::Row => write!(f, "Row ↕"), Axis::Column => write!(f, "Col ↔"), Axis::Page => write!(f, "Page ☰"), + Axis::None => write!(f, "None ∅"), } } } diff --git a/src/view/layout.rs b/src/view/layout.rs index 537ac85..0b7c40b 100644 --- a/src/view/layout.rs +++ b/src/view/layout.rs @@ -27,6 +27,8 @@ pub struct GridLayout { pub page_coords: Vec<(String, String)>, pub row_items: Vec, pub col_items: Vec, + /// Categories on `Axis::None` — hidden, implicitly aggregated. + pub none_cats: Vec, } impl GridLayout { @@ -46,6 +48,11 @@ impl GridLayout { .into_iter() .map(String::from) .collect(); + let none_cats: Vec = view + .categories_on(Axis::None) + .into_iter() + .map(String::from) + .collect(); let page_coords = page_cats .iter() @@ -77,6 +84,7 @@ impl GridLayout { page_coords, row_items, col_items, + none_cats, } } diff --git a/src/view/types.rs b/src/view/types.rs index fc4fe7a..57dcfba 100644 --- a/src/view/types.rs +++ b/src/view/types.rs @@ -148,12 +148,13 @@ impl View { self.col_offset = 0; } - /// Cycle axis for a category: Row → Column → Page → Row + /// Cycle axis for a category: Row → Column → Page → None → Row pub fn cycle_axis(&mut self, cat_name: &str) { let next = match self.axis_of(cat_name) { Axis::Row => Axis::Column, Axis::Column => Axis::Page, - Axis::Page => Axis::Row, + Axis::Page => Axis::None, + Axis::None => Axis::Row, }; self.set_axis(cat_name, next); self.selected = (0, 0); @@ -302,9 +303,17 @@ mod tests { } #[test] - fn cycle_axis_page_to_row() { + fn cycle_axis_page_to_none() { let mut v = view_with_cats(&["Region", "Product", "Time"]); v.cycle_axis("Time"); + assert_eq!(v.axis_of("Time"), Axis::None); + } + + #[test] + fn cycle_axis_none_to_row() { + let mut v = view_with_cats(&["Region", "Product", "Time"]); + v.set_axis("Time", Axis::None); + v.cycle_axis("Time"); assert_eq!(v.axis_of("Time"), Axis::Row); } @@ -351,7 +360,7 @@ mod prop_tests { fn each_category_on_exactly_one_axis(cats in unique_cat_names()) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } - let all_axes = [Axis::Row, Axis::Column, Axis::Page]; + let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None]; for c in &cats { let count = all_axes.iter() .filter(|&&ax| v.categories_on(ax).contains(&c.as_str())) @@ -377,7 +386,7 @@ mod prop_tests { fn set_axis_updates_axis_of( cats in unique_cat_names(), target_idx in 0usize..8, - axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)], + axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)], ) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); } @@ -392,7 +401,7 @@ mod prop_tests { fn set_axis_exclusive( cats in unique_cat_names(), target_idx in 0usize..8, - axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)], + axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)], ) { let mut v = View::new("T"); for c in &cats { v.on_category_added(c); }