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:
vangel76
2026-10-11 12:54:30 +02:00
co-authored by Claude Opus 5.5
parent a1dc03a484
commit c2d2f22e6a
15 changed files with 628 additions and 14 deletions
+4 -2
View File
@@ -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) |
+494
View File
@@ -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(&times, 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());
}
}
+2
View File
@@ -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
})
+72 -1
View File
@@ -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());
}
}
+1
View File
@@ -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
+34
View File
@@ -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",
+1
View File
@@ -317,6 +317,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Data Table…", "データ テーブル…"),
("Scenario Manager…", "シナリオ マネージャー…"),
("Goal Seek…", "ゴール シーク…"),
("Forecast\nSheet", "予測シート"),
("Spelling", "スペルチェック"),
("Check\nAccessibility", "アクセシビリティ チェック"),
("Threaded Comments", "スレッド化されたコメント"),
+1
View File
@@ -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" => "스레드 댓글",
+1
View File
@@ -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",
+1
View File
@@ -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" => "Комментарии с ветками",
+1
View File
@@ -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" => "线程式批注",
+3
View File
@@ -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
View File
@@ -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 -4
View File
@@ -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
View File
@@ -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 |