Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
236d04d
PivotTables: Show Values As, all 15 calculations, with base field and…
prxnvt Oct 12, 2026
37f10a8
PivotTables: group number fields into ranges (pivot.group by: range)
prxnvt Oct 12, 2026
df0446d
PivotTables: label, value and Top 10 filters on row and column fields
prxnvt Oct 12, 2026
dad6200
Merge origin/main into #498 (PivotTables: label, value and Top 10 fil…
Herr-Enklo Oct 13, 2026
72b2a77
PivotTables: Top N keeps items tied at the cut-off
Herr-Enklo Oct 13, 2026
6ad5eef
PivotTables: Group Selection, time grouping, calculated items and tim…
Herr-Enklo Oct 13, 2026
716d365
PivotTables: PivotTable Analyze and Design contextual tabs
Herr-Enklo Oct 13, 2026
b8a07a5
XLSX: PivotTable time grouping, Grouping dialog bounds, Group Selecti…
Herr-Enklo Oct 13, 2026
5372b27
XLSX: timelines round-trip with their caches, frames and PivotTable f…
Herr-Enklo Oct 13, 2026
dbf7dc7
PivotTables: GETPIVOTDATA and Generate GetPivotData
Herr-Enklo Oct 13, 2026
a09c523
PivotTables: field buttons with Label and Value Filters, pivot dialog…
Herr-Enklo Oct 13, 2026
8bed031
Merge feat/getpivotdata (#1189): PivotTable Analyze and Design tabs, …
Herr-Enklo Oct 13, 2026
ea9e1e2
XLSX: date groups keep their chronological order in Excel
Herr-Enklo Oct 13, 2026
8183701
Docs: PivotTable grouping, calculated items, timelines and filter menus
Herr-Enklo Oct 14, 2026
895b235
Timelines: the time level and "All Periods" follow the interface lang…
Herr-Enklo Oct 14, 2026
2714872
PR1207: preserve dormant PivotTable filter rules
Herr-Enklo Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GridCraft roadmap

**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~150–230 h away

> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (PivotTables: Group Selection, time grouping, calculated items, timelines, label and value filter menus; GETPIVOTDATA and the PivotTable tabs) · **Target:** Microsoft Excel (Microsoft 365)

GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
Expand All @@ -21,11 +21,11 @@ This page is the summary. The detail is in:

| Number | Value | Kind |
|---|---|---|
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 274 / 290 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 292 / 307 (95.1%), worksheet functions 514 / 523 (98.3%; 505 working, 96.6%) |
| **Ready for real work** | **~50%** | estimated from written weights (52.0); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
| Remaining to **beta** | **~160–240 Opus 5.5 agent-hours** | estimated; itemized in [roadmap.md](docs/roadmap.md#beta-gates) |
| Remaining to **beta** | **~150–230 Opus 5.5 agent-hours** | estimated; itemized in [roadmap.md](docs/roadmap.md#beta-gates) |
| Remaining to **full parity** | **~700–1,100 Opus 5.5 agent-hours** | estimated; sum of the dimension rows below |

| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
Expand Down Expand Up @@ -78,7 +78,7 @@ collaboration, mobile, AI).
| Editing, navigation, clipboard, fill | ~90% | ~70% | 15–25 | [ui-parity.md](docs/ui-parity.md) |
| Cell formatting, styles, conditional formatting | ~95% | ~80% | 8–14 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Sort, filter, tables, validation, outline | ~82% | ~70% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| PivotTables | ~65% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| PivotTables | ~78% | ~58% | 10–16 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| Charts and sparklines | ~50% | ~30% | 40–60 | [chart-parity.md](docs/chart-parity.md) |
| Page layout, print, PDF | ~80% | ~55% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
| What-if and analysis (Goal Seek, Solver, ToolPak, Forecast) | ~50% | ~40% | 22–35 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
Expand Down Expand Up @@ -132,6 +132,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-14 | PivotTables: timelines (Insert › Timeline, PivotTable Analyze › Insert Timeline, the Timeline tab) filter PivotTables by a period of a date field and round-trip through XLSX timeline parts (Excel shows the timeline but does not apply its saved selection yet) |
| 2026-10-14 | PivotTables: calculated items (Fields, Items & Sets › Calculated Item, Solve Order, List Formulas) with Excel's totals and restrictions, loaded from and saved to XLSX (Excel itself does not load the saved items yet) |
| 2026-10-14 | PivotTables: Group Selection and Ungroup (`Region2` / `Group1`), date and time grouping at several levels with the Grouping dialog's Starting at, Ending at and Number of days, with XLSX round-trip |
| 2026-10-14 | PivotTables: the report's field buttons and the field list open Label Filters ▸ / Value Filters ▸ and a Top 10 dialog; Top N keeps items tied at the cut-off (#498 review) |
| 2026-10-14 | PivotTables: GETPIVOTDATA (value field by caption or source field, row, column and report filter items, Excel's rules for totals and hidden items, recalculated on refresh, evaluated on open), Generate GetPivotData (clicking or arrowing onto a PivotTable value while typing a formula inserts it; on by default, also in File › Options › Formulas), and the PivotTable Analyze and Design contextual tabs in Excel 365's layout with German labels and key tips JT/JY |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
Expand All @@ -148,6 +153,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-12 | PivotTables: label filters (with wildcards), value filters and Top 10 (items, percent, sum) on row and column fields, with XLSX round-trip |
| 2026-10-11 | PivotTables: number fields group into ranges (Starting at, Ending at, By) with XLSX round-trip |
| 2026-10-11 | PivotTables: Show Values As covers all 15 calculations (% of, difference from, % of parent totals, % running total, ranks, index), with base field and base item, and XLSX round-trip |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
Expand Down Expand Up @@ -193,6 +201,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | PivotTables ~65/~45% → ~78/~58% (Group Selection, time grouping, calculated items, timelines, filter menus); full number 51.6 → 52.0; beta ~160–240 → ~150–230 h (gate #8 20–30 → 10–16 h); four progress log rows |
| 2026-10-14 | minor | Progress log row for GETPIVOTDATA and the PivotTable tabs |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
Expand Down
80 changes: 80 additions & 0 deletions crates/calc/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub const MAX_CELLS: u64 = 16_000_000;
/// formulas over them stay fast.
const FULL_SIZE_CELLS: u64 = 16_384;
const MAX_DEPTH: usize = 400;
/// GETPIVOTDATA takes a value field, a PivotTable and up to 126 field/item pairs.
const MAX_GETPIVOTDATA_ARGS: usize = 254;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Area {
Expand Down Expand Up @@ -732,6 +734,7 @@ impl<'h> Evaluator<'h> {
"INDEX" => self.index(args),
"XLOOKUP" => self.xlookup(args),
"CELL" => self.cell_info(args),
"GETPIVOTDATA" => self.getpivotdata(args),
"TRIMRANGE" => self.trimrange(args),
"SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args),
"TEXT" => {
Expand Down Expand Up @@ -1344,6 +1347,63 @@ impl<'h> Evaluator<'h> {
self.builtin("XLOOKUP", args)
}

/// GETPIVOTDATA(data_field, pivot_table, [field1, item1], …): a value shown in a PivotTable
/// report. `pivot_table` is any cell or range in the report; the report's index, written by
/// the engine with the report, says which cell the value field and items name
/// ([`gridcraft_model::PivotReport::lookup`]). An empty value cell counts as 0, as in Excel.
fn getpivotdata(&mut self, args: &[Expr]) -> Ev {
if args.len() < 2 || args.len() > MAX_GETPIVOTDATA_ARGS {
return err(CellError::Value);
}
let text = |v: Value| -> Result<String, CellError> { v.scalar().to_text() };
let data = match text(self.value(&args[0])) {
Ok(t) => t,
Err(e) => return err(e),
};
let area = match self.eval(&args[1]) {
Ev::R(areas) => match areas.first() {
Some(a) => *a,
None => return err(CellError::Ref),
},
Ev::V(Value::Error(e)) => return err(e),
_ => return err(CellError::Ref),
};
let mut pairs: Vec<(String, Value)> = Vec::with_capacity(args.len() / 2);
for pair in args.get(2..).unwrap_or_default().chunks(2) {
let field = match pair.first().map(|f| text(self.value(f))) {
Some(Ok(f)) => f,
Some(Err(e)) => return err(e),
None => return err(CellError::Ref),
};
// A field without its item names nothing.
let Some(item) = pair.get(1).map(|i| self.value(i).scalar()) else { return err(CellError::Ref) };
if let Value::Error(e) = item {
return err(e);
}
pairs.push((field, item));
}
let found = self.host.workbook().sheet(area.sheet).and_then(|sh| {
sh.pivots.iter().find_map(|p| {
let r = p.last_range.filter(|r| r.intersects(&area.range))?;
Some((r.start, p.report.0.clone()?))
})
});
let Some((origin, report)) = found else { return err(CellError::Ref) };
let named: Vec<(&str, &Value)> = pairs.iter().map(|(f, v)| (f.as_str(), v)).collect();
let (r, c) = match report.lookup(&data, &named) {
Ok(at) => at,
Err(e) => return err(e),
};
let (Some(row), Some(col)) = (origin.row.checked_add(r), origin.col.checked_add(c)) else { return err(CellError::Ref) };
if row >= MAX_ROWS || col >= MAX_COLS {
return err(CellError::Ref);
}
match self.host.cell_value(area.sheet, CellRef::new(row, col)) {
Value::Empty => Ev::V(Value::Number(0.0)),
v => Ev::V(v),
}
}

fn cell_info(&mut self, args: &[Expr]) -> Ev {
let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) {
Some(Ok(t)) => t.to_ascii_lowercase(),
Expand Down Expand Up @@ -1763,6 +1823,24 @@ pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
_ => dynamic = true,
},
// GETPIVOTDATA reads a cell of the report its reference points into. Refreshing a
// PivotTable recalculates everything; an edit inside the report must reach it too.
Expr::Call(n, args) if n == "GETPIVOTDATA" => {
if let Some(Expr::Ref(r)) = args.get(1) {
let range = r.range();
let mut reports = Vec::new();
push(&mut reports, r, range);
for a in reports {
let hits = wb
.sheet(a.sheet)
.into_iter()
.flat_map(|sh| sh.pivots.iter())
.filter_map(|p| p.last_range)
.filter(|pr| pr.intersects(&a.range));
out.extend(hits.map(|range| Area { sheet: a.sheet, range }));
}
}
}
Expr::Call(n, _)
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
{
Expand Down Expand Up @@ -1811,6 +1889,7 @@ fn is_special(n: &str) -> bool {
| "INDIRECT"
| "INDEX"
| "CELL"
| "GETPIVOTDATA"
| "TRIMRANGE"
| "SUBTOTAL"
| "AGGREGATE"
Expand Down Expand Up @@ -1849,6 +1928,7 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[
"INDIRECT",
"INDEX",
"CELL",
"GETPIVOTDATA",
"TRIMRANGE",
"SUBTOTAL",
"AGGREGATE",
Expand Down
43 changes: 43 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1266,3 +1266,46 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}

#[test]
fn getpivotdata_reads_the_report_index() {
use gridcraft_model::{PivotReport, PivotReportIndex, PivotTable, ReportDataField, ReportField, ReportItem, ReportKey};
// A report at D1:E4 as the engine writes it: a header, East and West, a grand total.
let mut t = T::new();
let item = |l: &str| ReportItem { value: Value::text(l), label: l.into() };
let key = |offset, items: &[Option<u32>]| ReportKey { offset, data: None, items: items.to_vec() };
let report = PivotReport {
data_fields: vec![ReportDataField { name: "Sum of Sales".into(), source: "Sales".into() }],
row_fields: vec![ReportField { name: "Region".into(), dates: false, items: vec![item("East"), item("West")] }],
rows: vec![key(1, &[Some(0)]), key(2, &[Some(1)]), key(3, &[None])],
cols: vec![ReportKey { offset: 1, data: Some(0), items: vec![] }],
label_cols: 1,
compact: true,
..PivotReport::default()
};
let pt = PivotTable {
anchor: c("D1"),
last_range: Some(gridcraft_core::RangeRef::parse("D1:E4").unwrap()),
report: PivotReportIndex(Some(std::sync::Arc::new(report))),
..PivotTable::default()
};
t.wb.sheet_mut(0).unwrap().pivots.push(pt);
for (at, v) in [("E2", "30"), ("E3", "12"), ("E4", "42")] {
t.set(at, v);
}
t.set("A1", r#"=GETPIVOTDATA("Sales",D1,"Region","west")"#);
t.set("A2", r#"=GETPIVOTDATA("Sum of Sales",E4)"#);
t.set("A3", r#"=GETPIVOTDATA("Sales",D1,"Region","North")"#);
assert_eq!(t.num("A1"), 12.0);
assert_eq!(t.num("A2"), 42.0);
assert_eq!(t.get("A3"), Value::Error(CellError::Ref));
// A changed report cell reaches the formulas that read it.
t.set("E3", "13");
assert_eq!(t.num("A1"), 13.0);
// An empty value cell reads as 0; without an index there is nothing to read.
t.set("E3", "");
assert_eq!(t.num("A1"), 0.0);
t.wb.sheet_mut(0).unwrap().pivots[0].report = PivotReportIndex::default();
t.set("A1", r#"=GETPIVOTDATA("Sales",D1,"Region","West")"#);
assert_eq!(t.get("A1"), Value::Error(CellError::Ref));
}
20 changes: 19 additions & 1 deletion crates/engine/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("chart.shapeFill", "Format", "Shape Styles", "Shape Fill"),
("chart.shapeOutline", "Format", "Shape Styles", "Shape Outline"),
// PivotTable Analyze / Design
("pivot.generateGetPivotData", "PivotTable Analyze", "PivotTable", "Generate GetPivotData"),
("pivot.fieldList", "PivotTable Analyze", "Show", "Field List"),
("pivot.refresh", "PivotTable Analyze", "Data", "Refresh"),
("pivot.changeSource", "PivotTable Analyze", "Data", "Change Data Source"),
Expand All @@ -315,8 +316,25 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("pivot.filter", "PivotTable Analyze", "Filter", "Filter"),
("pivot.sort", "PivotTable Analyze", "Sort", "Sort"),
("pivot.group", "PivotTable Analyze", "Group", "Group Field"),
("pivot.groupSelection", "PivotTable Analyze", "Group", "Group Selection"),
("pivot.ungroup", "PivotTable Analyze", "Group", "Ungroup"),
("pivot.calculatedItem", "PivotTable Analyze", "Calculations", "Calculated Item"),
("pivot.solveOrder", "PivotTable Analyze", "Calculations", "Solve Order"),
("pivot.listFormulas", "PivotTable Analyze", "Calculations", "List Formulas"),
("pivot.insertTimeline", "PivotTable Analyze", "Filter", "Insert Timeline"),
// Timeline (contextual)
("timeline.caption", "Timeline", "Timeline", "Timeline Caption"),
("timeline.reportConnections", "Timeline", "Timeline", "Report Connections"),
("timeline.style", "Timeline", "Timeline Styles", "Timeline Styles"),
("timeline.size", "Timeline", "Size", "Size"),
("timeline.showHeader", "Timeline", "Show", "Header"),
("timeline.showSelectionLabel", "Timeline", "Show", "Selection Label"),
("timeline.showScrollbar", "Timeline", "Show", "Scrollbar"),
("timeline.showTimeLevel", "Timeline", "Show", "Time Level"),
("pivot.collapse", "PivotTable Analyze", "Active Field", "Collapse Field"),
("pivot.delete", "PivotTable Analyze", "Actions", "Clear All"),
("pivot.clearAll", "PivotTable Analyze", "Actions", "Clear All"),
("pivot.clearFilters", "PivotTable Analyze", "Actions", "Clear Filters"),
("pivot.delete", "PivotTable Analyze", "Actions", "Delete PivotTable"),
];

/// Distinct catalog ids.
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/cmd/formulas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ pub fn function_list() -> Vec<Json> {
("ISREF", "Information", "ISREF(value)", "TRUE for references."),
("ISFORMULA", "Information", "ISFORMULA(reference)", "TRUE when the cell has a formula."),
("FORMULATEXT", "Lookup", "FORMULATEXT(reference)", "The formula of a cell as text."),
(
"GETPIVOTDATA",
"Lookup",
"GETPIVOTDATA(data_field, pivot_table, [field1, item1], ...)",
"Returns a value shown in a PivotTable report, named by its value field and items.",
),
("SHEET", "Information", "SHEET([value])", "Sheet number."),
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/cmd/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
"chart" => sh.charts.retain(|c| c.id != id),
"image" => sh.images.retain(|c| c.id != id),
"slicer" => sh.slicers.retain(|c| c.id != id),
"timeline" => sh.timelines.retain(|c| c.id != id),
_ => sh.shapes.retain(|c| c.id != id),
}
Ok(Json::Null)
Expand All @@ -1116,6 +1117,7 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"timeline" => sh.timelines.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
_ => None,
};
Expand Down
Loading