mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Data: Forecast Sheet (data.forecastSheet) (#322)
New sheet before the active one with the sorted history, a FORECAST.ETS forecast column, FORECAST.ETS.CONFINT lower/upper bounds, an optional FORECAST.ETS.STAT table, an Excel table over the data and a line or column chart. Parameters: timeline/values (default: the selection's first two columns), end or periods, confidence interval, seasonality, missing-point fill, duplicate aggregation, statistics, chart kind. Data ribbon button opens a form dialog; programmatic calls never do. gridcraft-functions exports Timeline to continue a timeline (numeric step or calendar months). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a1dc03a484
commit
c2d2f22e6a
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (worksheet functions 505 / 523; catalog 259 / 290) · **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.
|
||||
@@ -21,7 +21,7 @@ This page is the summary. The detail is in:
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 258 / 290 (89.0%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 259 / 290 (89.3%), worksheet functions 505 / 523 (96.6%; 496 working, 94.8%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [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) |
|
||||
@@ -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 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) |
|
||||
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
||||
| 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 |
|
||||
@@ -143,6 +144,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Catalog 259 / 290 (Forecast Sheet); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (FORECAST.ETS family); progress log row |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
//! Data › Forecast › Forecast Sheet: a new sheet with the history, a FORECAST.ETS column, the
|
||||
//! FORECAST.ETS.CONFINT confidence bounds, optional FORECAST.ETS.STAT statistics and a chart.
|
||||
//!
|
||||
//! Like Excel, the sheet holds a copy of the timeline and values (sorted by time) and live
|
||||
//! formulas over that copy, so the forecast follows edits to the copied history.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_functions::Timeline;
|
||||
use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use crate::selection::current_region;
|
||||
|
||||
const CMD: &str = "data.forecastSheet";
|
||||
/// Most history rows a forecast sheet copies.
|
||||
const MAX_HISTORY: u64 = 100_000;
|
||||
/// Most forecast rows.
|
||||
const MAX_PERIODS: u32 = 10_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"data.forecastSheet",
|
||||
"Forecast Sheet",
|
||||
["Data", "Forecast"],
|
||||
None,
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
has_doc,
|
||||
forecast_sheet
|
||||
)]
|
||||
}
|
||||
|
||||
/// The cells of a one-row or one-column range, at most `MAX_HISTORY + 1`.
|
||||
fn line_cells(r: RangeRef) -> Result<Vec<CellRef>> {
|
||||
if r.count() > MAX_HISTORY + 1 {
|
||||
return Err(bad(CMD, format!("at most {MAX_HISTORY} rows of history")));
|
||||
}
|
||||
if r.width() == 1 {
|
||||
Ok((r.start.row..=r.end.row).map(|row| CellRef::new(row, r.start.col)).collect())
|
||||
} else if r.height() == 1 {
|
||||
Ok((r.start.col..=r.end.col).map(|col| CellRef::new(r.start.row, col)).collect())
|
||||
} else {
|
||||
Err(bad(CMD, "the timeline and the values are each one row or one column"))
|
||||
}
|
||||
}
|
||||
|
||||
/// A range parameter on any sheet (whole columns trimmed to the used range).
|
||||
fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
|
||||
let d = s.doc()?;
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = match sh {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
|
||||
let r = if r.is_full_cols() || r.is_full_rows() {
|
||||
match d.wb.sheet(si).and_then(|s| s.used_range()) {
|
||||
Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)),
|
||||
None => RangeRef::cell(r.start),
|
||||
}
|
||||
} else {
|
||||
r
|
||||
};
|
||||
Ok((si, r))
|
||||
}
|
||||
|
||||
/// (sheet, timeline cells, value cells).
|
||||
fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)> {
|
||||
let text = |k: &str| str_param(p, k).map(str::trim).filter(|t| !t.is_empty());
|
||||
match (text("timeline"), text("values")) {
|
||||
(Some(t), Some(v)) => {
|
||||
let (ts, tr) = range_on(s, t)?;
|
||||
let (vs, vr) = range_on(s, v)?;
|
||||
if ts != vs {
|
||||
return Err(bad(CMD, "the timeline and the values must be on one sheet"));
|
||||
}
|
||||
Ok((ts, line_cells(tr)?, line_cells(vr)?))
|
||||
}
|
||||
(None, None) => {
|
||||
let d = s.doc()?;
|
||||
let (si, r) = match text("range") {
|
||||
Some(t) => range_on(s, t)?,
|
||||
None => {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = d.selection.current();
|
||||
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
|
||||
}
|
||||
};
|
||||
if r.width() < 2 {
|
||||
return Err(EngineError::Other("Select a timeline column and a values column for the forecast.".into()));
|
||||
}
|
||||
let col = |c: u32| RangeRef::new(CellRef::new(r.start.row, c), CellRef::new(r.end.row, c));
|
||||
Ok((si, line_cells(col(r.start.col))?, line_cells(col(r.start.col + 1))?))
|
||||
}
|
||||
_ => Err(bad(CMD, "give both `timeline` and `values`, or neither")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number parameter given as a number or as text.
|
||||
fn number(p: &Json, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::Number(n)) => Ok(n.as_f64()),
|
||||
Some(Json::String(t)) if t.trim().is_empty() => Ok(None),
|
||||
Some(Json::String(t)) => {
|
||||
t.trim().trim_end_matches('%').trim().parse::<f64>().map(Some).map_err(|_| bad(CMD, format!("`{key}` is not a number")))
|
||||
}
|
||||
Some(_) => Err(bad(CMD, format!("`{key}` is not a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
struct Options {
|
||||
seasonality: u32,
|
||||
fill: u8,
|
||||
aggregate: Option<u8>,
|
||||
confidence: Option<f64>,
|
||||
statistics: bool,
|
||||
column_chart: bool,
|
||||
}
|
||||
|
||||
fn options(p: &Json) -> Result<Options> {
|
||||
let seasonality = match p.get("seasonality") {
|
||||
Some(Json::String(t)) if t.trim().is_empty() || t.trim().eq_ignore_ascii_case("auto") => 1,
|
||||
None | Some(Json::Null) => 1,
|
||||
_ => match number(p, "seasonality")? {
|
||||
Some(n) if n == n.trunc() && (0.0..=8760.0).contains(&n) => n as u32,
|
||||
_ => return Err(bad(CMD, "`seasonality` is \"auto\", 0 (none) or a season length from 2 to 8,760")),
|
||||
},
|
||||
};
|
||||
let fill = match str_param(p, "fillMissing").map(|t| t.trim().to_ascii_lowercase()) {
|
||||
None => 1,
|
||||
Some(t) if t.is_empty() || t == "interpolate" || t == "interpolation" => 1,
|
||||
Some(t) if t == "zeros" || t == "zero" => 0,
|
||||
Some(t) => return Err(bad(CMD, format!("`fillMissing` is interpolate or zeros, not `{t}`"))),
|
||||
};
|
||||
let aggregate = match str_param(p, "aggregate").map(|t| t.trim().to_ascii_lowercase()) {
|
||||
None => None,
|
||||
Some(t) => match t.as_str() {
|
||||
"" | "average" => None,
|
||||
"count" => Some(2),
|
||||
"counta" => Some(3),
|
||||
"max" => Some(4),
|
||||
"median" => Some(5),
|
||||
"min" => Some(6),
|
||||
"sum" => Some(7),
|
||||
_ => return Err(bad(CMD, format!("unknown `aggregate`: `{t}`"))),
|
||||
},
|
||||
};
|
||||
let confidence = if bool_param(p, "confidenceInterval").unwrap_or(true) {
|
||||
let c = number(p, "confidence")?.unwrap_or(0.95);
|
||||
let c = if c >= 1.0 { c / 100.0 } else { c };
|
||||
if !(c > 0.0 && c < 1.0) {
|
||||
return Err(bad(CMD, "`confidence` is between 0 and 1 (or 0 and 100%)"));
|
||||
}
|
||||
Some(c)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let column_chart = match str_param(p, "chart").map(|t| t.trim().to_ascii_lowercase()) {
|
||||
None => false,
|
||||
Some(t) if t.is_empty() || t == "line" => false,
|
||||
Some(t) if t == "column" => true,
|
||||
Some(t) => return Err(bad(CMD, format!("`chart` is line or column, not `{t}`"))),
|
||||
};
|
||||
Ok(Options { seasonality, fill, aggregate, confidence, statistics: bool_param(p, "statistics").unwrap_or(false), column_chart })
|
||||
}
|
||||
|
||||
fn col_letters(c: u32) -> String {
|
||||
gridcraft_core::col_to_letters(c)
|
||||
}
|
||||
|
||||
fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let opts = options(p)?;
|
||||
let (si, tcells, vcells) = source(s, p)?;
|
||||
if tcells.len() != vcells.len() {
|
||||
return Err(bad(CMD, "the timeline and the values need the same number of cells"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let sys = d.wb.date_system;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
// A text first cell is the header.
|
||||
let header = tcells.first().is_some_and(|c| sh.value(*c).is_text()) || vcells.first().is_some_and(|c| sh.value(*c).is_text());
|
||||
let name_of = |c: Option<&CellRef>, fallback: &str| match c.map(|c| sh.value(*c)) {
|
||||
Some(v) if header && !v.is_empty() => v.display(),
|
||||
_ => fallback.to_string(),
|
||||
};
|
||||
let (tname, vname) = (name_of(tcells.first(), "Timeline"), name_of(vcells.first(), "Values"));
|
||||
let skip = usize::from(header);
|
||||
let mut rows: Vec<(f64, Value)> = Vec::new();
|
||||
for (t, v) in tcells.iter().zip(&vcells).skip(skip) {
|
||||
let (tv, vv) = (sh.value(*t), sh.value(*v));
|
||||
match tv {
|
||||
Value::Number(x) => rows.push((x, if matches!(vv, Value::Number(_)) { vv } else { Value::Empty })),
|
||||
Value::Empty if vv.is_empty() => {}
|
||||
_ => return Err(EngineError::Other(format!("The timeline must hold numbers or dates ({} isn't one).", t.a1()))),
|
||||
}
|
||||
}
|
||||
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let times: Vec<f64> = rows.iter().map(|r| r.0).collect();
|
||||
let timeline =
|
||||
Timeline::detect(×, sys).ok_or_else(|| EngineError::Other("The timeline needs at least two points a constant step apart.".into()))?;
|
||||
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
|
||||
return Err(EngineError::Other("There are no values to forecast.".into()));
|
||||
}
|
||||
let end = match p.get("end") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(end) => Some(end),
|
||||
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let periods = match end {
|
||||
Some(end) => steps_to(&timeline, end)?,
|
||||
None => match number(p, "periods")? {
|
||||
Some(n) if n >= 1.0 && n <= f64::from(MAX_PERIODS) => n.ceil() as u32,
|
||||
Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {MAX_PERIODS}"))),
|
||||
None => (rows.len() as u32).div_ceil(3).max(1),
|
||||
},
|
||||
};
|
||||
let future: Vec<f64> =
|
||||
(1..=periods).map(|k| timeline.after_last(k)).collect::<Option<_>>().ok_or_else(|| bad(CMD, "the forecast runs past the last date"))?;
|
||||
let fmt_of = |c: Option<&CellRef>| c.map(|c| d.wb.styles.get(sh.style_id(*c)).num_fmt.clone()).unwrap_or_default();
|
||||
let (tfmt, vfmt) = (fmt_of(tcells.get(skip)), fmt_of(vcells.get(skip)));
|
||||
let sheet_name = d.wb.next_sheet_name();
|
||||
let table_name = (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into());
|
||||
let chart_id = d.wb.next_object_id();
|
||||
let table_id = chart_id.saturating_add(1);
|
||||
|
||||
// Columns: A timeline, B values, C forecast, D/E bounds.
|
||||
let n = rows.len() as u32;
|
||||
let last = n + 1; // 1-based row of the last history point
|
||||
let end_row = last + periods; // 1-based row of the last forecast
|
||||
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
|
||||
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
|
||||
if let Some(a) = opts.aggregate {
|
||||
tail.push_str(&format!(",{a}"));
|
||||
}
|
||||
let mut headers = vec![tname, vname.clone(), format!("Forecast({vname})")];
|
||||
if opts.confidence.is_some() {
|
||||
headers.push(format!("Lower Confidence Bound({vname})"));
|
||||
headers.push(format!("Upper Confidence Bound({vname})"));
|
||||
}
|
||||
// Table column names are unique.
|
||||
for i in 1..headers.len() {
|
||||
let base = headers.get(i).cloned().unwrap_or_default();
|
||||
let mut name = base.clone();
|
||||
let mut k = 2;
|
||||
while headers.iter().take(i).any(|h| h.eq_ignore_ascii_case(&name)) {
|
||||
name = format!("{base}{k}");
|
||||
k += 1;
|
||||
}
|
||||
if let Some(h) = headers.get_mut(i) {
|
||||
*h = name;
|
||||
}
|
||||
}
|
||||
let width = headers.len() as u32;
|
||||
let stats_col = width + 1;
|
||||
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
|
||||
|
||||
let at = s.doc()?.wb.active_sheet;
|
||||
let index = edit(s, |cx| {
|
||||
let at = at.min(cx.wb.sheets.len());
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let tstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = tfmt.clone());
|
||||
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
|
||||
let mut sheet = Sheet::new(sheet_name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
};
|
||||
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
|
||||
let mut cell = Cell::formula(Formula::new(&text));
|
||||
cell.style = style;
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
};
|
||||
for (c, h) in headers.iter().enumerate() {
|
||||
put(&mut sheet, 0, c as u32, Value::text(h.as_str()), StyleId::DEFAULT);
|
||||
}
|
||||
for (i, (t, v)) in rows.iter().enumerate() {
|
||||
let r = i as u32 + 1;
|
||||
put(&mut sheet, r, 0, Value::Number(*t), tstyle);
|
||||
if !v.is_empty() {
|
||||
put(&mut sheet, r, 1, v.clone(), vstyle);
|
||||
}
|
||||
}
|
||||
// The last history point starts the forecast lines, so they join the values.
|
||||
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
|
||||
if opts.confidence.is_some() {
|
||||
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
|
||||
}
|
||||
let (tl, vl) = (hist(0), hist(1));
|
||||
for (k, t) in future.iter().enumerate() {
|
||||
let r = last + k as u32 + 1; // 1-based
|
||||
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
|
||||
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
}
|
||||
if opts.statistics {
|
||||
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
|
||||
put(&mut sheet, 0, stats_col + 1, Value::text("Value"), bold);
|
||||
for (k, label) in ["Alpha", "Beta", "Gamma", "MASE", "SMAPE", "MAE", "RMSE"].iter().enumerate() {
|
||||
let r = k as u32 + 1;
|
||||
put(&mut sheet, r, stats_col, Value::text(*label), StyleId::DEFAULT);
|
||||
formula(&mut sheet, r, stats_col + 1, format!("=FORECAST.ETS.STAT({vl},{tl},{},{tail})", k + 1), StyleId::DEFAULT);
|
||||
}
|
||||
}
|
||||
for c in 0..width {
|
||||
sheet.cols.insert(
|
||||
c,
|
||||
gridcraft_model::LineInfo {
|
||||
size: Some(match c {
|
||||
0 | 1 => 90.0,
|
||||
2 => 130.0,
|
||||
_ => 230.0,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row - 1, width - 1));
|
||||
sheet.tables.push(Table {
|
||||
id: table_id,
|
||||
name: table_name.clone(),
|
||||
range,
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
|
||||
style: "TableStyleMedium2".into(),
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
});
|
||||
let q = gridcraft_formula::quote_sheet(&sheet_name);
|
||||
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
|
||||
let series = (1..width)
|
||||
.map(|c| Series {
|
||||
name: Some(format!("{q}!${}$1", col_letters(c))),
|
||||
categories: Some(col_ref(0)),
|
||||
values: col_ref(c),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
smooth: false,
|
||||
})
|
||||
.collect();
|
||||
sheet.charts.push(Chart {
|
||||
id: chart_id,
|
||||
kind: if opts.column_chart { ChartKind::ColumnClustered } else { ChartKind::Line },
|
||||
anchor: Anchor { cell: CellRef::new(1, chart_col), dx: 0.0, dy: 0.0, width: 480.0, height: 288.0, mode: Default::default() },
|
||||
title: None,
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
});
|
||||
cx.wb.sheets.insert(at, Arc::new(sheet));
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
cx.wb.active_sheet = at;
|
||||
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
|
||||
cx.structural = true;
|
||||
Ok(at)
|
||||
})?;
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
|
||||
}
|
||||
|
||||
/// Forecast rows needed to reach `end` (rounded up to whole steps).
|
||||
fn steps_to(timeline: &Timeline, end: f64) -> Result<u32> {
|
||||
let steps = timeline.steps_after_last(end).ok_or_else(|| bad(CMD, "`end` is not on the timeline"))?;
|
||||
if steps < 1e-9 {
|
||||
return Err(EngineError::Other("The forecast end must be after the last point of the timeline.".into()));
|
||||
}
|
||||
let steps = steps.ceil();
|
||||
if steps > f64::from(MAX_PERIODS) {
|
||||
return Err(bad(CMD, format!("at most {MAX_PERIODS} forecast steps")));
|
||||
}
|
||||
Ok(steps as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session_with_history() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut rows = vec![json!(["Month", "Sales"])];
|
||||
// y = 10 + 2t with a four-step season.
|
||||
let season = [3.0, -1.0, 4.0, -6.0];
|
||||
for t in 1..=16 {
|
||||
rows.push(json!([t, 10.0 + 2.0 * t as f64 + season[(t - 1) % 4]]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn value(s: &Session, sheet: usize, a1: &str) -> Value {
|
||||
s.doc().unwrap().wb.sheet(sheet).unwrap().value(CellRef::parse(a1).unwrap())
|
||||
}
|
||||
|
||||
fn formula(s: &Session, sheet: usize, a1: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(sheet).unwrap().cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_sheet_builds_table_formulas_and_chart() {
|
||||
let mut s = session_with_history();
|
||||
s.execute("selection.set", json!({"range": "A1:B17"})).unwrap();
|
||||
let r = s.execute("data.forecastSheet", json!({"periods": 4, "statistics": true})).unwrap();
|
||||
assert_eq!(r["history"], 16);
|
||||
assert_eq!(r["periods"], 4);
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
// Inserted before the data sheet and activated.
|
||||
assert_eq!(wb.active_sheet, 0);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
|
||||
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
|
||||
assert_eq!(value(&s, 0, "D1"), Value::text("Lower Confidence Bound(Sales)"));
|
||||
assert_eq!(value(&s, 0, "E1"), Value::text("Upper Confidence Bound(Sales)"));
|
||||
assert_eq!(formula(&s, 0, "C17"), "B17");
|
||||
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
|
||||
assert_eq!(formula(&s, 0, "D18"), "C18-FORECAST.ETS.CONFINT(A18,$B$2:$B$17,$A$2:$A$17,0.95,1,1)");
|
||||
assert_eq!(formula(&s, 0, "H2"), "FORECAST.ETS.STAT($B$2:$B$17,$A$2:$A$17,1,1,1)");
|
||||
// Future timeline and the exact continuation of the seasonal series (t = 17 → 10 + 34 + 3).
|
||||
assert_eq!(value(&s, 0, "A18"), Value::Number(17.0));
|
||||
assert_eq!(value(&s, 0, "A21"), Value::Number(20.0));
|
||||
let c18 = value(&s, 0, "C18").as_f64().unwrap();
|
||||
assert!((c18 - 47.0).abs() < 1e-6, "{c18}");
|
||||
let (lo, hi) = (value(&s, 0, "D19").as_f64().unwrap(), value(&s, 0, "E19").as_f64().unwrap());
|
||||
assert!(lo <= hi);
|
||||
assert_eq!(sh.tables.first().map(|t| t.range.a1()), Some("A1:E21".into()));
|
||||
let chart = sh.charts.first().unwrap();
|
||||
assert_eq!(chart.series.len(), 4);
|
||||
assert_eq!(chart.series[1].values, format!("{}!$C$2:$C$21", r["sheet"].as_str().unwrap()));
|
||||
// One undo step removes the sheet again.
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_sheet_parameters_and_errors() {
|
||||
let mut s = session_with_history();
|
||||
let r = s
|
||||
.execute(
|
||||
"data.forecastSheet",
|
||||
json!({"timeline": "A2:A17", "values": "B2:B17", "end": "22", "confidenceInterval": false, "seasonality": 4, "fillMissing": "zeros", "aggregate": "sum", "chart": "column"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["periods"], 6);
|
||||
assert_eq!(value(&s, 0, "B1"), Value::text("Values"));
|
||||
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,4,0,7)");
|
||||
assert_eq!(value(&s, 0, "D1"), Value::Empty);
|
||||
let sh = s.doc().unwrap().wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.charts.first().map(|c| c.kind), Some(gridcraft_model::ChartKind::ColumnClustered));
|
||||
// Errors: end before the data, bad seasonality, uneven timeline, one column only.
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "end": 3})).is_err());
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "seasonality": 9000})).is_err());
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!B1:B17"})).is_err());
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod draw;
|
||||
pub mod edit;
|
||||
pub mod extra;
|
||||
pub mod file;
|
||||
pub mod forecast;
|
||||
pub mod format;
|
||||
pub mod formulas;
|
||||
pub mod insert;
|
||||
@@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(spelling::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
v.extend(print::specs());
|
||||
v
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! Prediction intervals use the model's h-step forecast variance (the book's Table 6.1, class 1):
|
||||
//! `σ²·(1 + Σ_{j=1}^{h−1} (α + αβ·j + γ·[j mod m = 0])²)`, σ² = SSE / n, with the normal quantile.
|
||||
|
||||
use gridcraft_core::date::{datetime_from_serial, days_in_month_in};
|
||||
use gridcraft_core::date::{datetime_from_serial, days_in_month_in, serial_from_ymd};
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
use crate::special::norm_inv;
|
||||
@@ -391,6 +391,62 @@ fn interpolate(slots: &[Option<f64>]) -> Vec<f64> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A timeline's step, for continuing it past its last point (the Forecast Sheet's future rows).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Timeline {
|
||||
axis: Axis,
|
||||
/// Position of the last time stamp.
|
||||
last: i64,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// The step of a set of time stamps (any order, duplicates allowed): a constant numeric step
|
||||
/// (missing points allowed) or, for whole-day dates on one day of the month, calendar months.
|
||||
/// `None` without at least two distinct, finite time stamps or without a constant step.
|
||||
pub fn detect(times: &[f64], sys: DateSystem) -> Option<Timeline> {
|
||||
if times.iter().any(|t| !t.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut sorted = times.to_vec();
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
sorted.dedup();
|
||||
let axis = detect_axis(&sorted, sys).ok()?;
|
||||
let last = axis.position(*sorted.last()?)?.round();
|
||||
// Like the functions, at most 30% of the points may be missing.
|
||||
if !(0.0..MAX_POINTS as f64).contains(&last) || (last + 1.0 - sorted.len() as f64) > MAX_MISSING * (last + 1.0) {
|
||||
return None;
|
||||
}
|
||||
Some(Timeline { axis, last: last as i64 })
|
||||
}
|
||||
|
||||
/// Steps from the last time stamp to `t` (fractional between steps, negative before it).
|
||||
pub fn steps_after_last(&self, t: f64) -> Option<f64> {
|
||||
let p = self.axis.position(t)? - self.last as f64;
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
|
||||
/// The time stamp `k` steps after the last one.
|
||||
pub fn after_last(&self, k: u32) -> Option<f64> {
|
||||
let pos = self.last.checked_add(i64::from(k))?;
|
||||
match self.axis {
|
||||
Axis::Linear { t0, step } => {
|
||||
let t = t0 + pos as f64 * step;
|
||||
t.is_finite().then_some(t)
|
||||
}
|
||||
Axis::Months { sys, month0, step, day } => {
|
||||
let month = month0.checked_add(pos.checked_mul(step)?)?;
|
||||
let (y, m) = (month.div_euclid(12), month.rem_euclid(12) + 1);
|
||||
let dim = i64::from(days_in_month_in(sys, i32::try_from(y).ok()?, u32::try_from(m).ok()?));
|
||||
let d = day.map_or(dim, |d| i64::from(d).min(dim));
|
||||
if y < 1900 {
|
||||
return None;
|
||||
}
|
||||
serial_from_ymd(sys, y, m, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Seasonality detection
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1002,4 +1058,19 @@ mod tests {
|
||||
is_err(ets(vec![n(1e15), range(&[1.0, 2.0, 3.0]), range(&[0.0, 1.0, 1e12])]), CellError::Num);
|
||||
is_err(ets(vec![n(1e308), range(&ys), range(&ts)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timelines_continue_past_their_last_point() {
|
||||
let tl = Timeline::detect(&[3.0, 1.0, 2.0, 2.0, 5.0], DateSystem::D1900).expect("numeric step");
|
||||
assert_eq!(tl.after_last(1), Some(6.0));
|
||||
assert_eq!(tl.steps_after_last(8.5), Some(3.5));
|
||||
// Month ends: 2024-01-31 (45322), 2024-02-29 (45351), 2024-03-31 (45382).
|
||||
let tl = Timeline::detect(&[45322.0, 45351.0, 45382.0], DateSystem::D1900).expect("months");
|
||||
assert_eq!(tl.after_last(1), Some(45412.0)); // 2024-04-30
|
||||
assert_eq!(tl.steps_after_last(45412.0), Some(1.0));
|
||||
assert!(Timeline::detect(&[1.0, 2.0, 4.5], DateSystem::D1900).is_none());
|
||||
assert!(Timeline::detect(&[1.0, 2.0, 9.0], DateSystem::D1900).is_none(), "too many missing points");
|
||||
assert!(Timeline::detect(&[1.0], DateSystem::D1900).is_none());
|
||||
assert!(Timeline::detect(&[1.0, f64::NAN], DateSystem::D1900).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ pub(crate) mod stat;
|
||||
pub(crate) mod text;
|
||||
pub(crate) mod util;
|
||||
|
||||
pub use ets::Timeline;
|
||||
pub use stat::{aggregate_values, aggregate_values_k};
|
||||
|
||||
/// An evaluated argument. Ranges arrive as `Value::Array` (blank cells = `Value::Empty`) with
|
||||
|
||||
@@ -317,6 +317,40 @@ impl Dialog {
|
||||
json!({"header": true}),
|
||||
),
|
||||
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
|
||||
"forecastSheet" => Dialog::form(
|
||||
"forecastSheet",
|
||||
"Create Forecast Worksheet",
|
||||
"data.forecastSheet",
|
||||
vec![
|
||||
Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" },
|
||||
Text { key: "values", label: "Values range (blank: second column of the selection):" },
|
||||
Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" },
|
||||
Check { key: "confidenceInterval", label: "Confidence interval" },
|
||||
Text { key: "confidence", label: "Confidence level (%):" },
|
||||
Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" },
|
||||
Choice {
|
||||
key: "fillMissing",
|
||||
label: "Fill missing points using",
|
||||
options: vec![("interpolate", "Interpolation"), ("zeros", "Zeros")],
|
||||
},
|
||||
Choice {
|
||||
key: "aggregate",
|
||||
label: "Aggregate duplicates using",
|
||||
options: vec![
|
||||
("average", "Average"),
|
||||
("count", "Count"),
|
||||
("counta", "CountA"),
|
||||
("max", "Max"),
|
||||
("median", "Median"),
|
||||
("min", "Min"),
|
||||
("sum", "Sum"),
|
||||
],
|
||||
},
|
||||
Check { key: "statistics", label: "Include forecast statistics" },
|
||||
Choice { key: "chart", label: "Chart", options: vec![("line", "Line chart"), ("column", "Column chart")] },
|
||||
],
|
||||
json!({"confidenceInterval": true, "confidence": "95", "seasonality": "auto", "fillMissing": "interpolate", "aggregate": "average", "chart": "line"}),
|
||||
),
|
||||
"subtotal" => Dialog::form(
|
||||
"subtotal",
|
||||
"Subtotal",
|
||||
|
||||
@@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Data Table…", "データ テーブル…"),
|
||||
("Scenario Manager…", "シナリオ マネージャー…"),
|
||||
("Goal Seek…", "ゴール シーク…"),
|
||||
("Forecast\nSheet", "予測シート"),
|
||||
("Spelling", "スペルチェック"),
|
||||
("Check\nAccessibility", "アクセシビリティ チェック"),
|
||||
("Threaded Comments", "スレッド化されたコメント"),
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Data Table…" => "데이터 표…",
|
||||
"Scenario Manager…" => "시나리오 관리자…",
|
||||
"Goal Seek…" => "목표값 찾기…",
|
||||
"Forecast\nSheet" => "예측 시트",
|
||||
"Spelling" => "맞춤법 검사",
|
||||
"Check\nAccessibility" => "접근성\n검사",
|
||||
"Threaded Comments" => "스레드 댓글",
|
||||
|
||||
@@ -169,6 +169,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Data Table…" => "Tabela de Dados…",
|
||||
"Scenario Manager…" => "Gerenciador de Cenários…",
|
||||
"Goal Seek…" => "Buscar Objetivo…",
|
||||
"Forecast\nSheet" => "Planilha de\nPrevisão",
|
||||
"Spelling" => "Ortografia",
|
||||
"Check\nAccessibility" => "Verificar\nAcessibilidade",
|
||||
"Threaded Comments" => "Comentários Encadeados",
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Data Table…" => "Таблица данных…",
|
||||
"Scenario Manager…" => "Диспетчер сценариев…",
|
||||
"Goal Seek…" => "Подбор параметра…",
|
||||
"Forecast\nSheet" => "Лист\nпрогноза",
|
||||
"Spelling" => "Орфография",
|
||||
"Check\nAccessibility" => "Проверка\nдоступности",
|
||||
"Threaded Comments" => "Комментарии с ветками",
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Data Table…" => "数据表…",
|
||||
"Scenario Manager…" => "方案管理器…",
|
||||
"Goal Seek…" => "单变量求解…",
|
||||
"Forecast\nSheet" => "预测工作表",
|
||||
"Spelling" => "拼写检查",
|
||||
"Check\nAccessibility" => "检查\n辅助功能",
|
||||
"Threaded Comments" => "线程式批注",
|
||||
|
||||
@@ -1568,6 +1568,9 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
||||
],
|
||||
);
|
||||
});
|
||||
if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() {
|
||||
app.open_dialog("forecastSheet", json!({}));
|
||||
}
|
||||
sep(ui);
|
||||
ui.vertical(|ui| {
|
||||
if small_button(ui, Icon::Group, "Group", "Group", false).clicked() {
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13, §15: FORECAST.ETS family landed) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§13: FORECAST.ETS family and Forecast Sheet landed; §15) · **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
|
||||
@@ -98,8 +98,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h.
|
||||
|
||||
### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F)
|
||||
- `data.solver`, `data.analysisToolPak`, `data.forecastSheet` missing (the FORECAST.ETS family the
|
||||
Forecast Sheet builds on is in). **Estimate:** 18–30 h.
|
||||
- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the
|
||||
FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 15–25 h.
|
||||
|
||||
### 14. Page Layout view and print polish (U, F)
|
||||
- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR
|
||||
@@ -149,6 +149,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | §13: Forecast Sheet landed (#322) |
|
||||
| 2026-10-11 | minor | §13, §15: FORECAST.ETS family landed (#314) |
|
||||
| 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 |
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|---|
|
||||
@@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
|
||||
| Draw | 4 | 4 | 100% |
|
||||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
| Data | 20 | 28 | 71% |
|
||||
| Data | 21 | 28 | 75% |
|
||||
| Review | 9 | 18 | 50% |
|
||||
| View | 18 | 20 | 90% |
|
||||
| Automate | 3 | 3 | 100% |
|
||||
@@ -240,7 +240,7 @@ Implemented:
|
||||
- `formulas.calculateNow` Calculate Now (Calculation)
|
||||
- `formulas.calculateSheet` Calculate Sheet (Calculation)
|
||||
|
||||
## Data (20 / 28, 71%)
|
||||
## Data (21 / 28, 75%)
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -259,6 +259,7 @@ Implemented:
|
||||
- `data.scenarioManager` Scenario Manager (Forecast)
|
||||
- `data.goalSeek` Goal Seek (Forecast)
|
||||
- `data.dataTable` Data Table (Forecast)
|
||||
- `data.forecastSheet` Forecast Sheet (Forecast)
|
||||
- `data.group` Group (Outline)
|
||||
- `data.ungroup` Ungroup (Outline)
|
||||
- `data.subtotal` Subtotal (Outline)
|
||||
@@ -271,7 +272,6 @@ Missing:
|
||||
- `data.stocks` Stocks (Data Types)
|
||||
- `data.geography` Geography (Data Types)
|
||||
- `data.relationships` Relationships (Data Tools)
|
||||
- `data.forecastSheet` Forecast Sheet (Forecast)
|
||||
- `data.analyzeData` Analyze Data (Analysis)
|
||||
- `data.solver` Solver (Analysis)
|
||||
- `data.analysisToolPak` Data Analysis (Analysis)
|
||||
|
||||
+5
-4
@@ -1,10 +1,10 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Forecast Sheet dialog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
separately in [parity-checklist.md](parity-checklist.md) (258 / 290).
|
||||
separately in [parity-checklist.md](parity-checklist.md) (259 / 290).
|
||||
|
||||
Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes),
|
||||
`crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes
|
||||
@@ -65,8 +65,8 @@ shortcut lists for Windows and Mac, then fill the gaps: 6–10 h.
|
||||
|
||||
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
|
||||
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
|
||||
Seek, Scenario Manager and others exist. Missing or partial: Options, Solver, Data Analysis,
|
||||
Forecast Sheet, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
|
||||
Seek, Scenario Manager, Forecast Sheet and others exist. Missing or partial: Options, Solver, Data Analysis,
|
||||
Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
|
||||
Workbook Links, Accessibility checker.
|
||||
|
||||
## Accessibility
|
||||
@@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Forecast Sheet dialog (#322); catalog 259 / 290 |
|
||||
| 2026-10-10 | minor | Chart resize bug (#190) |
|
||||
| 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |
|
||||
|
||||
Reference in New Issue
Block a user