From 400ab591cbb462ca769ff4563e0e3a13256f0a2c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:24:50 +0200 Subject: [PATCH] PivotTables: calculated fields (pivot.calculatedField) (#304) A value field can be a calculated field whose formula is evaluated on the sums of the fields it names for each report cell (Excel's documented semantics). Command to add/change/delete, dialog from the field pane, XLSX cacheField formula round trip. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/pivot.rs | 168 ++++++++++++- crates/engine/src/lib.rs | 1 + crates/engine/src/pivot.rs | 68 ++++- crates/engine/src/pivot_calc.rs | 362 +++++++++++++++++++++++++++ crates/model/src/features.rs | 13 + crates/ui-egui/src/dialogs.rs | 7 + crates/ui-egui/src/pivot_pane.rs | 3 + crates/ui-egui/tests/interaction.rs | 1 + crates/xlsx/src/pivot.rs | 59 ++++- crates/xlsx/src/tests/pivot_tests.rs | 28 +++ docs/gaps.md | 7 +- docs/parity-checklist.md | 11 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- 15 files changed, 704 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/pivot_calc.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd7..a0179e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 27e0aab..9fa0264 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -43,6 +43,15 @@ pub fn specs() -> Vec { has_doc, summarize_table ), + cmd!( + "pivot.calculatedField", + "Calculated Field", + ["PivotTable Analyze", "Calculations"], + None, + "{pivot?, name, formula?: \"=Sales*0.1\" (fields bare or 'quoted'; evaluated on each cell's sums), delete?: bool, values?: bool (add a new field to Values, default true)} adds, changes or deletes a calculated field; without `name` opens the dialog → {name, formula, calculatedFields}", + has_doc, + calculated_field + ), cmd!(query "pivot.fields", "PivotTable Fields", [], None, "{pivot?} → source fields (name, numeric, date, samples, areas) and the current layout", has_doc, fields), cmd!(query "pivot.fieldList", "Field List", ["PivotTable Analyze", "Show"], None, "{pivot?} (same as pivot.fields)", has_doc, fields), cmd!(query "pivot.list", "PivotTables", [], None, "{} → every PivotTable in the workbook", has_doc, list), @@ -320,6 +329,19 @@ fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) } +/// The calculated field named `field`, if any. +fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> { + pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim())) +} + +/// The canonical name of a source or calculated field. +fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result { + match calc_field(pt, field) { + Some(c) if src.col(field).is_none() => Ok(c.name.clone()), + _ => canonical(cmd, src, field), + } +} + fn unique_value_name(pt: &PivotTable, base: &str, except: Option) -> String { let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n)); if !taken(base) { @@ -343,7 +365,11 @@ fn add_to_area( name: Option<&str>, position: Option, ) -> Result<()> { - let field = canonical(cmd, src, field)?; + let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some(); + if is_calc && area != "values" { + return Err(other("A calculated field can only be placed in the Values area.")); + } + let field = canonical_any(cmd, pt, src, field)?; match area { "rows" | "columns" => { if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { @@ -369,7 +395,7 @@ fn add_to_area( if pt.values.len() >= pv::MAX_VALUE_FIELDS { return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS))); } - let numeric = src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); + let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count }); let base = match name { Some(n) if !n.trim().is_empty() => { @@ -663,6 +689,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" }, "style": pt.style, "showHeaders": pt.show_headers, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), }) } @@ -702,6 +729,11 @@ fn fields(s: &mut Session, p: &Json) -> Result { .collect(), Err(_) => vec![], }; + let mut list = list; + for c in &pt.calculated_fields { + let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] }; + list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula})); + } if let Some(m) = out.as_object_mut() { m.insert("fields".into(), Json::Array(list)); if let Ok(src) = &src { @@ -743,7 +775,76 @@ fn add_field(s: &mut Session, p: &Json) -> Result { let pos = position_param(p); update(s, p, |pt, src, _| { add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?; - Ok(json!({"field": canonical(C, src, &field)?, "area": area})) + Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area})) + }) +} + +fn calculated_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.calculatedField"; + let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { + // Menu-style call: the Insert Calculated Field dialog. + let (si, pi) = find_pivot(s, p)?; + let d = s.doc()?; + let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into()); + let pivot = pt.name.clone(); + s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"}))); + return ok(); + }; + if name.is_empty() || name.chars().count() > 255 { + return Err(bad(C, "`name` must be 1 to 255 characters")); + } + let delete = bool_param(p, "delete").unwrap_or(false); + let formula = str_param(p, "formula").map(|f| { + let f = f.trim(); + if f.starts_with('=') { f.to_string() } else { format!("={f}") } + }); + let to_values = bool_param(p, "values").unwrap_or(true); + if !delete && formula.is_none() { + return Err(bad(C, "missing `formula`")); + } + update(s, p, |pt, src, _| { + let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name)); + if delete { + let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?; + let gone = pt.calculated_fields.remove(i); + pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name)); + return Ok(json!({"name": gone.name, "deleted": true})); + } + let formula = formula.clone().unwrap_or_default(); + if src.col(&name).is_some() { + return Err(other("PivotTable field name already exists.")); + } + let canon = match existing { + Some(i) => { + let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?; + c.formula = formula.clone(); + c.name.clone() + } + None => { + if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS { + return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS))); + } + pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() }); + name.clone() + } + }; + let set = crate::pivot_calc::CalcSet::new(pt, &src.headers); + let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?; + if let Some(e) = set.error(i) { + return Err(bad(C, e.to_string())); + } + if set.is_cyclic(i) { + return Err(bad(C, format!("`{canon}` refers to itself through its formula"))); + } + if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) { + add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?; + } + Ok(json!({ + "name": canon, + "formula": formula, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + })) }) } @@ -1280,8 +1381,8 @@ fn recommended(s: &mut Session, p: &Json) -> Result { #[cfg(test)] mod tests { - use gridcraft_core::{CellRef, Value}; - use serde_json::json; + use gridcraft_core::{CellError, CellRef, Value}; + use serde_json::{Value as Json, json}; use crate::Session; @@ -1550,6 +1651,63 @@ mod tests { s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); } + #[test] + fn calculated_field_on_sums() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap(); + assert_eq!(r["formula"], "=Sales / Units"); + assert_eq!(text(&s, "C3"), "Sum of Price"); + assert_eq!(text(&s, "A4"), "East"); + // East: 415 sales over 33 units, from the sums. + let east = val(&s, "C4").as_f64().unwrap(); + assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}"); + // The grand total applies the formula to the totals, not the sum of the rows. + assert_eq!(text(&s, "A7"), "Grand Total"); + let total = val(&s, "C7").as_f64().unwrap(); + assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}"); + // Quoted names, functions, and another calculated field. + s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap(); + assert_eq!(val(&s, "D4"), Value::Number(25.2)); + // Division by zero shows #DIV/0! cells. + s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0)); + // Change and delete. + s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Number(66.0)); + s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap(); + assert_eq!(pt(&s).values.len(), 3); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // The field list shows calculated fields. + let f = s.execute("pivot.fields", json!({})).unwrap(); + assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true)); + } + + #[test] + fn calculated_field_errors() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string(); + assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus")); + assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells")); + assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error")); + assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists")); + assert!(e(json!({"name": "Bad"})).contains("formula")); + assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone")); + // A cycle is refused. + s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap(); + s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap(); + let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string(); + assert!(err.contains("itself"), "{err}"); + // Calculated fields only go to Values; nothing changed after the failures. + assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err()); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // Without a name the dialog opens (menu-style call). + s.ui_requests.clear(); + s.execute("pivot.calculatedField", json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField")); + } + #[test] fn replace_protection() { let mut s = sales(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1..a09ed00 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod display; pub mod fill; pub mod io; pub mod pivot; +pub mod pivot_calc; pub mod sample; pub mod selection; pub mod tables; diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index bc27317..3e5c896 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -12,6 +12,8 @@ use gridcraft_core::date::datetime_from_serial; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::*; +use crate::pivot_calc::CalcSet; + /// Largest source block read (cells). pub const MAX_SOURCE_CELLS: u64 = 10_000_000; /// Largest report written (cells). @@ -666,13 +668,44 @@ fn flatten_cols( type AggMap = HashMap<(Vec, Vec), Vec>; +/// Where a value field's numbers come from. +#[derive(Clone, Copy)] +enum ValSrc { + /// A source column. + Col(usize), + /// A calculated field (index into the PivotTable's calculated fields). + Calc(usize), +} + /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); - let vals: Vec<(usize, &PivotValue)> = pt.values.iter().filter_map(|v| src.col(&v.source_col).map(|c| (c, v))).collect(); + // Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`). + let calc = CalcSet::new(pt, &src.headers); + let vals: Vec<(ValSrc, &PivotValue)> = pt + .values + .iter() + .filter_map(|v| match src.col(&v.source_col) { + Some(c) => Some((ValSrc::Col(c), v)), + None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)), + }) + .collect(); + // Source columns summed for the calculated fields, accumulated after the value fields. + let mut extra: Vec = Vec::new(); + for (vs, _) in &vals { + if let ValSrc::Calc(i) = vs { + for name in calc.source_refs(*i) { + if let Some(c) = src.col(&name) + && !extra.contains(&c) + { + extra.push(c); + } + } + } + } if rows.len() + cols.len() > MAX_AXIS_FIELDS { return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields.")); } @@ -728,8 +761,13 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result Option { - let (_, v) = vals.get(vi)?; - aggs.get(&(rp.to_vec(), cp.to_vec())).and_then(|a| a.get(vi)).map(|a| a.result(v.func)) + let (vs, v) = vals.get(vi)?; + let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?; + match vs { + ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)), + ValSrc::Calc(i) => { + let sum = |name: &str| -> Value { + src.col(name) + .and_then(|c| extra.iter().position(|x| *x == c)) + .and_then(|j| accs.get(k + j)) + .map(|a| a.result(PivotFunc::Sum)) + .unwrap_or(Value::Number(0.0)) + }; + Some(calc.value(wb, *i, &sum)) + } + } }; let num = |v: &Option| v.as_ref().and_then(Value::as_f64); let ratio = |a: Value, b: Option| -> Value { @@ -935,7 +986,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result> = vals .iter() - .map(|(c, v)| match v.show_as { + .map(|(vs, v)| match v.show_as { PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => { Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into())) } @@ -943,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result v.number_format.clone().or_else(|| match v.func { PivotFunc::Count | PivotFunc::CountNumbers => None, _ => { - let f = fmt_of(*c); + let f = match vs { + ValSrc::Col(c) => fmt_of(*c), + ValSrc::Calc(_) => "General".to_string(), + }; (f != "General").then_some(f) } }), diff --git a/crates/engine/src/pivot_calc.rs b/crates/engine/src/pivot_calc.rs new file mode 100644 index 0000000..014ad2d --- /dev/null +++ b/crates/engine/src/pivot_calc.rs @@ -0,0 +1,362 @@ +//! Calculated PivotTable fields: a formula over other fields (`=Sales*0.1`, +//! `='Unit Price'*Units`, `=IF(Units>0,Sales/Units,0)`). +//! +//! As Microsoft documents for Excel, the formula operates on the *sums* of the fields it names +//! for each report cell (items, subtotals and grand totals alike), not record by record: for +//! `=Sales*1.2` a cell shows 1.2 × the sum of Sales of that cell's records. Fields are written +//! bare (`Sales`) or in single quotes (`'Unit Price'`); cell references and defined names are not +//! allowed. A calculated field may use other calculated fields; a cycle evaluates to `#REF!`. + +use gridcraft_core::{CellError, CellRef, Value}; +use gridcraft_formula::Expr; +use gridcraft_model::{PivotTable, Workbook}; + +/// Longest formula accepted. +pub const MAX_FORMULA_LEN: usize = 8_192; +/// Most calculated fields per PivotTable. +pub const MAX_CALC_FIELDS: usize = 256; +/// Deepest chain of calculated fields using calculated fields. +const MAX_DEPTH: usize = 64; +const PREFIX: &str = "__pcf"; + +fn placeholder(i: usize) -> String { + format!("{PREFIX}{i}") +} + +/// A parsed formula: field names replaced by placeholder names `__pcf0`, `__pcf1`, … in the +/// order of `refs`. +#[derive(Clone, Debug)] +pub struct Compiled { + pub body: Expr, + /// Canonical names of the fields used. + pub refs: Vec, +} + +/// Parses a calculated-field formula. `field` maps a written name to the canonical name of a +/// source or calculated field. +pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option) -> Result { + let text = formula.trim(); + let text = text.strip_prefix('=').unwrap_or(text).trim(); + if text.is_empty() { + return Err("The formula is empty.".into()); + } + if text.len() > MAX_FORMULA_LEN { + return Err("The formula is too long.".into()); + } + let chars: Vec = text.chars().collect(); + let at = |i: usize| chars.get(i).copied(); + let mut out = String::with_capacity(text.len() + 16); + let mut refs: Vec = Vec::new(); + let slot = |name: String, refs: &mut Vec| -> String { + let i = refs.iter().position(|r| r.eq_ignore_ascii_case(&name)).unwrap_or_else(|| { + refs.push(name); + refs.len() - 1 + }); + placeholder(i) + }; + let mut i = 0; + while let Some(c) = at(i) { + match c { + '"' => { + out.push('"'); + i += 1; + loop { + match at(i) { + None => return Err("The formula has an unterminated text value.".into()), + Some('"') if at(i + 1) == Some('"') => { + out.push_str("\"\""); + i += 2; + } + Some('"') => { + out.push('"'); + i += 1; + break; + } + Some(x) => { + out.push(x); + i += 1; + } + } + } + } + '\'' => { + let mut name = String::new(); + i += 1; + loop { + match at(i) { + None => return Err("The formula has an unterminated field name.".into()), + Some('\'') if at(i + 1) == Some('\'') => { + name.push('\''); + i += 2; + } + Some('\'') => { + i += 1; + break; + } + Some(x) => { + name.push(x); + i += 1; + } + } + } + let canon = field(&name).ok_or_else(|| format!("There's no field named '{name}'."))?; + out.push_str(&slot(canon, &mut refs)); + } + c if c.is_ascii_digit() || (c == '.' && at(i + 1).is_some_and(|d| d.is_ascii_digit())) => { + while at(i).is_some_and(|d| d.is_ascii_digit() || d == '.') { + out.push(at(i).unwrap_or('0')); + i += 1; + } + if matches!(at(i), Some('e' | 'E')) { + let sign = usize::from(matches!(at(i + 1), Some('+' | '-'))); + if at(i + 1 + sign).is_some_and(|d| d.is_ascii_digit()) { + for _ in 0..=sign { + out.push(at(i).unwrap_or('0')); + i += 1; + } + while at(i).is_some_and(|d| d.is_ascii_digit()) { + out.push(at(i).unwrap_or('0')); + i += 1; + } + } + } + } + c if c.is_alphabetic() || c == '_' => { + let start = i; + while at(i).is_some_and(|d| d.is_alphanumeric() || d == '_' || d == '.') { + i += 1; + } + let word: String = chars.get(start..i).map(|w| w.iter().collect()).unwrap_or_default(); + let mut j = i; + while at(j).is_some_and(char::is_whitespace) { + j += 1; + } + let is_call = at(j) == Some('('); + match field(&word) { + Some(canon) if !is_call => out.push_str(&slot(canon, &mut refs)), + _ => out.push_str(&word), + } + } + c => { + out.push(c); + i += 1; + } + } + } + let body = gridcraft_formula::parse(&out).map_err(|_| "The formula contains an error.".to_string())?; + let mut problem: Option = None; + body.walk(&mut |e| { + if problem.is_some() { + return; + } + match e { + Expr::Name(n) if n.starts_with(PREFIX) => {} + Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")), + Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()), + _ => {} + } + }); + match problem { + Some(p) => Err(p), + None => Ok(Compiled { body, refs }), + } +} + +fn literal(v: &Value) -> Expr { + match v { + Value::Number(n) => Expr::Number(*n), + Value::Error(e) => Expr::Error(*e), + Value::Text(t) => Expr::Text(t.clone()), + Value::Bool(b) => Expr::Bool(*b), + Value::Empty => Expr::Number(0.0), + Value::Array(_) => literal(&v.scalar()), + } +} + +/// Evaluates a compiled formula with the given field values (in `refs` order). +pub fn eval(wb: &Workbook, c: &Compiled, args: &[Value]) -> Value { + let expr = if c.refs.is_empty() { + c.body.clone() + } else { + let mut call = Vec::with_capacity(c.refs.len() * 2 + 1); + for i in 0..c.refs.len() { + call.push(Expr::Name(placeholder(i))); + call.push(literal(args.get(i).unwrap_or(&Value::Empty))); + } + call.push(c.body.clone()); + Expr::Call("LET".into(), call) + }; + match gridcraft_calc::recalc::evaluate_expr(wb, 0, CellRef::default(), &expr).scalar() { + Value::Empty => Value::Number(0.0), + v => v, + } +} + +/// A PivotTable's calculated fields, compiled against its source headers. +pub struct CalcSet { + names: Vec, + compiled: Vec>, +} + +impl CalcSet { + pub fn new(pt: &PivotTable, headers: &[String]) -> CalcSet { + let names: Vec = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| c.name.clone()).collect(); + let lookup = |n: &str| -> Option { + let n = n.trim(); + headers.iter().chain(names.iter()).find(|h| h.trim().eq_ignore_ascii_case(n)).cloned() + }; + let compiled = pt.calculated_fields.iter().take(MAX_CALC_FIELDS).map(|c| compile(&c.formula, &lookup)).collect(); + CalcSet { names, compiled } + } + + pub fn index(&self, name: &str) -> Option { + self.names.iter().position(|n| n.eq_ignore_ascii_case(name.trim())) + } + + /// The compile error of a calculated field, if any. + pub fn error(&self, i: usize) -> Option<&str> { + self.compiled.get(i).and_then(|c| c.as_ref().err()).map(String::as_str) + } + + /// Source fields a calculated field needs, directly or through other calculated fields. + pub fn source_refs(&self, i: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen = vec![false; self.names.len()]; + let mut stack = vec![i]; + while let Some(j) = stack.pop() { + if !seen.get(j).copied().unwrap_or(true) { + if let Some(s) = seen.get_mut(j) { + *s = true; + } + if let Some(Ok(c)) = self.compiled.get(j) { + for r in &c.refs { + match self.index(r) { + Some(k) => stack.push(k), + None => { + if !out.iter().any(|o| o.eq_ignore_ascii_case(r)) { + out.push(r.clone()); + } + } + } + } + } + } + } + out + } + + /// Whether a calculated field reaches itself through the fields it uses. + pub fn is_cyclic(&self, i: usize) -> bool { + let mut seen = vec![false; self.names.len()]; + let mut stack: Vec = match self.compiled.get(i) { + Some(Ok(c)) => c.refs.iter().filter_map(|r| self.index(r)).collect(), + _ => vec![], + }; + while let Some(j) = stack.pop() { + if j == i { + return true; + } + if seen.get(j).copied().unwrap_or(true) { + continue; + } + if let Some(s) = seen.get_mut(j) { + *s = true; + } + if let Some(Ok(c)) = self.compiled.get(j) { + stack.extend(c.refs.iter().filter_map(|r| self.index(r))); + } + } + false + } + + /// The value of calculated field `i` for one report cell; `sum` gives a source field's sum + /// over that cell's records. + pub fn value(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value) -> Value { + let mut memo: Vec> = vec![None; self.names.len()]; + let mut active = vec![false; self.names.len()]; + self.value_in(wb, i, sum, &mut memo, &mut active, 0) + } + + fn value_in(&self, wb: &Workbook, i: usize, sum: &dyn Fn(&str) -> Value, memo: &mut [Option], active: &mut [bool], depth: usize) -> Value { + if let Some(Some(v)) = memo.get(i) { + return v.clone(); + } + if depth > MAX_DEPTH || active.get(i).copied().unwrap_or(true) { + return Value::Error(CellError::Ref); + } + let Some(Ok(c)) = self.compiled.get(i) else { return Value::Error(CellError::Name) }; + if let Some(a) = active.get_mut(i) { + *a = true; + } + let args: Vec = c + .refs + .iter() + .map(|r| match self.index(r) { + Some(j) => self.value_in(wb, j, sum, memo, active, depth + 1), + None => sum(r), + }) + .collect(); + let v = eval(wb, c, &args); + if let Some(a) = active.get_mut(i) { + *a = false; + } + if let Some(m) = memo.get_mut(i) { + *m = Some(v.clone()); + } + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fields(n: &str) -> Option { + ["Sales", "Units", "Unit Price", "Q1"].iter().find(|f| f.eq_ignore_ascii_case(n)).map(|f| f.to_string()) + } + + #[test] + fn compiles_field_names() { + let c = compile("= Sales*0.1 + 'Unit Price'/units - Sales", &fields).unwrap(); + assert_eq!(c.refs, vec!["Sales", "Unit Price", "Units"]); + // A field named like a cell reference is still a field; functions stay functions. + let c = compile("=ROUND(Q1*1.5E2,0)", &fields).unwrap(); + assert_eq!(c.refs, vec!["Q1"]); + let wb = Workbook::new(); + assert_eq!(eval(&wb, &c, &[Value::Number(2.0)]), Value::Number(300.0)); + assert!(compile("=Sales*Bogus", &fields).unwrap_err().contains("Bogus")); + assert!(compile("=A1*2", &fields).is_err()); + assert!(compile("='Nope'*2", &fields).is_err()); + assert!(compile("=Sales*(", &fields).is_err()); + assert!(compile("=", &fields).is_err()); + assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err()); + } + + #[test] + fn deep_and_cyclic_chains_are_bounded() { + // 300 calculated fields, each using the previous one, and a two-field cycle. + let mut pt = PivotTable::default(); + pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "C0".into(), formula: "=Sales+1".into() }); + for i in 1..300 { + pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: format!("C{i}"), formula: format!("=C{}+1", i - 1) }); + } + pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() }); + pt.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() }); + let set = CalcSet::new(&pt, &["Sales".to_string()]); + let wb = Workbook::new(); + let sum = |_: &str| Value::Number(10.0); + assert_eq!(set.value(&wb, 0, &sum), Value::Number(11.0)); + assert_eq!(set.value(&wb, 10, &sum), Value::Number(21.0)); + // Past the depth bound (the list itself is capped at MAX_CALC_FIELDS). + assert_eq!(set.value(&wb, 200, &sum), Value::Error(CellError::Ref)); + assert_eq!(set.index("P"), None, "P is past the cap of {MAX_CALC_FIELDS} fields"); + // A small cycle within the cap. + let mut small = PivotTable::default(); + small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "P".into(), formula: "=Q".into() }); + small.calculated_fields.push(gridcraft_model::PivotCalcField { name: "Q".into(), formula: "=P*2".into() }); + let set = CalcSet::new(&small, &["Sales".to_string()]); + assert!(set.is_cyclic(0)); + assert_eq!(set.value(&wb, 0, &sum), Value::Error(CellError::Ref)); + assert_eq!(set.source_refs(0), Vec::::new()); + } +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b9fe2ce..7a4f732 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -699,6 +699,18 @@ pub struct PivotTable { pub show_headers: bool, /// Area written by the last refresh. pub last_range: Option, + /// Calculated fields, usable in the Values area by name. + pub calculated_fields: Vec, +} + +/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in +/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotCalcField { + pub name: String, + /// Formula text, starting with `=`. + pub formula: String, } impl Default for PivotTable { @@ -720,6 +732,7 @@ impl Default for PivotTable { style: "PivotStyleLight16".into(), show_headers: true, last_range: None, + calculated_fields: vec![], } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb..4236512 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -480,6 +480,13 @@ impl Dialog { d } "goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})), + "pivotCalculatedField" => Dialog::form( + "pivotCalculatedField", + "Insert Calculated Field", + "pivot.calculatedField", + vec![Text { key: "name", label: "Name:" }, Text { key: "formula", label: "Formula:" }], + json!({"pivot": p("pivot"), "name": p("name"), "formula": p("formula")}), + ), "goToSpecial" => Dialog::form( "goToSpecial", "Go To Special", diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index ff8714d..14d69db 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -136,6 +136,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if ui.button("Refresh").clicked() { app.run_or_alert("pivot.refresh", json!({"pivot": name})); } + if ui.button("Calculated Field…").clicked() { + app.run_or_alert("pivot.calculatedField", json!({"pivot": name})); + } egui::ComboBox::from_id_salt("pv_layout").selected_text("Report Layout").show_ui(ui, |ui| { for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] { if ui.button(l).clicked() { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 84f20a6..3666f41 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -227,6 +227,7 @@ fn dialogs_open_and_close() { "commandSearch", "spelling", "goTo", + "pivotCalculatedField", "about", ] { h.state_mut().open_dialog(name, json!({})); diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 3e6e241..1d16ab3 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -17,7 +17,9 @@ use std::fmt::Write as _; use gridcraft_core::date::datetime_from_serial; use gridcraft_core::{CellRef, DateSystem, RangeRef, Value, number_to_text}; use gridcraft_formula::quote_sheet; -use gridcraft_model::{PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Workbook}; +use gridcraft_model::{ + PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue, Workbook, +}; use crate::IoError; use crate::read::Ctx; @@ -234,6 +236,8 @@ struct CField { index: HashMap, itemized: bool, group: Option, + /// A calculated field's formula (without `=`); such fields aren't database fields. + formula: Option, } impl CField { @@ -439,6 +443,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option Option Option, sys: DateSystem) -> String { for f in &c.fields { let fmt = if f.group.is_some() { 14 } else { 0 }; let _ = write!(s, " = pt.values.iter().filter_map(|v| find_db(&v.source_col).map(|i| (i, v))).collect(); + let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); + let vals: Vec<(usize, &PivotValue)> = + pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); let axis_field = |i: usize| -> Option<&PivotField> { let p = c.axis_map.iter().position(|x| *x == Some(i))?; pt.rows.iter().chain(pt.columns.iter()).nth(p) @@ -830,6 +859,9 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if vals.iter().any(|(j, _)| *j == i) { s.push_str(" dataField=\"1\""); } + if f.formula.is_some() { + s.push_str(" dragToRow=\"0\" dragToCol=\"0\" dragToPage=\"0\""); + } match pt.layout { PivotLayout::Compact => {} PivotLayout::Outline => s.push_str(" compact=\"0\""), @@ -990,7 +1022,8 @@ struct FieldIn { items: Vec, /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. group: Option<(Option, Option, Vec)>, - formula: bool, + /// A calculated field's formula. + formula: Option, } impl FieldIn { @@ -1097,7 +1130,7 @@ fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option database: f.flag("databaseField", true), items, group, - formula: f.attr("formula").is_some(), + formula: f.attr("formula").map(str::to_string), }); } } @@ -1154,7 +1187,7 @@ fn items_of(e: &El) -> Vec<&El> { /// (source column name, date grouping) of a cache field used on an axis. fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { let f = cache.fields.get(i)?; - if f.formula { + if f.formula.is_some() { cx.warn("calculated PivotTable fields are not supported"); return None; } @@ -1292,10 +1325,6 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option PivotFunc::Sum, Some(s) => func_from(s).unwrap_or_else(|| { @@ -1334,6 +1363,18 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option"), "{c1}"); + assert!(c1.contains("formula=\"'Price'*0.1\"") || c1.contains("formula=\"'Price'*0.1\""), "{c1}"); + let t1 = String::from_utf8(parts.get("xl/pivotTables/pivotTable1.xml").unwrap().clone()).unwrap(); + assert!(t1.contains(" **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§8: calculated fields landed) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -70,9 +70,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- routine in business. - **Estimate:** 28–42 h (`.xls` read 20–30, encryption 8–12). [file-format-parity.md](file-format-parity.md) -### 8. PivotTables: no slicers, calculated fields, PivotCharts; unverified in Excel (F) +### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, - `insert.pivotChart`, `pivot.calculatedField`, `table.insertSlicer`; GETPIVOTDATA missing. + `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields done). - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | §8: calculated fields added | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d..0b0032c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -21,7 +21,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Table Design | 12 | 13 | 92% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | -| PivotTable Analyze | 12 | 13 | 92% | +| PivotTable Analyze | 13 | 13 | 100% | | PivotTable Design | 4 | 4 | 100% | ## File (10 / 11, 91%) @@ -380,13 +380,14 @@ Implemented: - `chart.shapeFill` Shape Fill (Shape Styles) - `chart.shapeOutline` Shape Outline (Shape Styles) -## PivotTable Analyze (12 / 13, 92%) +## PivotTable Analyze (13 / 13, 100%) Implemented: - `pivot.fieldList` Field List (Show) - `pivot.refresh` Refresh (Data) - `pivot.changeSource` Change Data Source (Data) +- `pivot.calculatedField` Fields, Items & Sets (Calculations) - `pivot.addField` Add Field (Fields) - `pivot.removeField` Remove Field (Fields) - `pivot.moveField` Move Field (Fields) @@ -397,10 +398,6 @@ Implemented: - `pivot.collapse` Collapse Field (Active Field) - `pivot.delete` Clear All (Actions) -Missing: - -- `pivot.calculatedField` Fields, Items & Sets (Calculations) - ## PivotTable Design (4 / 4, 100%) Implemented: diff --git a/docs/roadmap.md b/docs/roadmap.md index b0c8eb8..062dfc7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; milestones moved here from ROADMAP.md, beta gates added) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTable calculated fields) · **Target:** Microsoft Excel (Microsoft 365) Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); numbers and method are in @@ -27,7 +27,7 @@ workflow, would make GridCraft pre-alpha. | Build a model: enter data and formulas, recalc, save XLSX, reopen | yes | 492 working functions, dynamic arrays, LET/LAMBDA, names, tables; XLSX round-trip tests (`crates/xlsx/src/tests/roundtrip.rs`). Edge-case bugs (#57 15-digit equality, stale spills #133/#139) give wrong results in specific cases, not in the workflow | 0 (fixes are beta work, gap #6) | | Open a colleague's XLSX, edit, save, hand it back | partial, not blocking | Typical workbooks open and save with cells, styles, CF, validation, tables, names and pivots; unmodelled parts are dropped with a warning (gap #3); Excel-side repair prompts unverified (gap #1); ~100 MB files fail (#175) | 0 for alpha (gaps #1–#3 are beta gates) | | Format and present: number formats, styles, conditional formatting, print or PDF | yes | Full number-format language, 47 cell styles, all CF kinds, Page Break Preview, PDF export and printing. PDF can't carry non-Latin text (gap #5) | 0 | -| Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Slicers and calculated fields missing (gap #8) | 0 | +| Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Calculated fields done; slicers missing (gap #8) | 0 | | Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter and combo charts are created, edited and survive save and reopen; histogram, box, waterfall, funnel, treemap, sunburst and stock lose their kind in XLSX (fix in PRs #158, #161); no axis options or trendlines (gap #4) | 0 for alpha (2–4 h to merge the kind fixes) | | Import and export CSV | yes | Encoding and delimiter detection on read, CSV/TSV write; exports raw rather than displayed values (gap #19) | 0 | @@ -112,6 +112,7 @@ to check our files, and Windows machines with the reported GPU drivers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | PivotTable calculated fields landed | | 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges | | 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) | | 2026-10-10 | major | Created: current focus, beta gates (~160–240 h), milestones moved from ROADMAP.md, post-beta ranking | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01..2697eef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTables: calculated fields) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -84,7 +84,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | -| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | +| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | PivotTables: calculated fields (`pivot.calculatedField`) | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |