v2.6.0

<bmx-planning-grid>

Budgeting and forecasting in the page: accounts, cost centres or products down the side, months and quarters across, and versions - Budget, Forecast, Actual, scenarios - to switch between and compare.

14 properties · 2 events · 21 methods · 6 parts

Example

Type into a month, or type a quarter's or a group's total to spread it. +10% grows a cell or a total; Ctrl+Shift+H holds cells so spreading leaves them alone.

Show markup
<bmx-planning-grid id="ex-pg" label="Operating plan 2026" scale="thousands" version="forecast" compare="budget" user="Finance"></bmx-planning-grid>
<p class="note" id="ex-pg-out" role="status" style="margin: 12px 0 0">Type into a month, or type a quarter's or a group's total to spread it. +10% grows a cell or a total; Ctrl+Shift+H holds cells so spreading leaves them alone.</p>

<script type="module">
  await customElements.whenDefined('bmx-planning-grid');
  const grid = document.getElementById('ex-pg');
  const out = document.getElementById('ex-pg-out');
  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  const periods = months.map((label, i) => ({ id: `2026-${String(i + 1).padStart(2, '0')}`, label, group: `Q${Math.floor(i / 3) + 1}`, year: 'FY2026', closed: i < 9 || undefined }));
  // A month's share of the year for a business that sells more before the year end.
  const season = [0.07, 0.07, 0.09, 0.08, 0.08, 0.09, 0.07, 0.06, 0.09, 0.09, 0.1, 0.11];
  const year = (total, shape = season, wobble = 0) => shape.map((s, i) => Math.round(total * s * (1 + wobble * Math.sin(i * 1.7))));
  const flat = (monthly, growth = 0) => months.map((_, i) => Math.round(monthly * (1 + growth * i)));
  const budget = {
    licences: year(1800000),
    subscriptions: flat(210000, 0.02),
    services: year(600000, season.map(() => 1 / 12)),
    hosting: flat(38000, 0.015),
    support: flat(42000),
    people: flat(310000, 0.01),
    marketing: year(900000),
    travel: flat(18000),
    rent: flat(26000),
    headcount: [48, 48, 50, 51, 52, 52, 54, 55, 55, 57, 58, 60],
  };
  // Actuals for January to September: near the budget, a little off.
  const actual = Object.fromEntries(Object.entries(budget).map(([row, values]) => [row, values.map((v, i) => (i < 9 ? Math.round(v * (1 + 0.06 * Math.sin(i + row.length))) : null))]));
  // The forecast for October to December, a touch more cautious than the budget.
  const forecast = Object.fromEntries(Object.entries(budget).map(([row, values]) => [row, values.map((v, i) => (i >= 9 ? Math.round(v * (row === 'licences' ? 0.94 : 1.01)) : null))]));
  grid.plan = {
    name: 'Operating plan 2026',
    currency: 'GBP',
    periods,
    rows: [
      { id: 'revenue', code: '4000', label: 'Revenue' },
      { id: 'licences', code: '4100', label: 'Licences', parent: 'revenue' },
      { id: 'subscriptions', code: '4200', label: 'Subscriptions', parent: 'revenue' },
      { id: 'services', code: '4300', label: 'Professional services', parent: 'revenue' },
      { id: 'cogs', code: '5000', label: 'Cost of sales', favourable: 'down' },
      { id: 'hosting', code: '5100', label: 'Hosting', parent: 'cogs', favourable: 'down' },
      { id: 'support', code: '5200', label: 'Support', parent: 'cogs', favourable: 'down' },
      { id: 'gross_margin', label: 'Gross margin', formula: 'revenue - cogs' },
      { id: 'gm_pct', label: 'Gross margin %', formula: 'IF(revenue = 0, 0, gross_margin / revenue)', format: 'percent' },
      { id: 'opex', code: '6000', label: 'Operating expenses', favourable: 'down' },
      { id: 'people', code: '6100', label: 'Salaries and benefits', parent: 'opex', favourable: 'down' },
      { id: 'marketing', code: '6200', label: 'Marketing', parent: 'opex', favourable: 'down' },
      { id: 'travel', code: '6300', label: 'Travel', parent: 'opex', favourable: 'down' },
      { id: 'rent', code: '6400', label: 'Rent and facilities', parent: 'opex', favourable: 'down', locked: true },
      { id: 'ebitda', label: 'EBITDA', formula: 'gross_margin - opex', description: 'Earnings before interest, tax, depreciation and amortisation.' },
      { id: 'ebitda_ytd', label: 'EBITDA, year to date', formula: 'YTD(ebitda)', aggregate: 'last' },
      { id: 'headcount', label: 'Headcount', format: 'integer', aggregate: 'average' },
    ],
    versions: [
      { id: 'budget', label: 'Budget 2026', kind: 'budget' },
      { id: 'actual', label: 'Actual', kind: 'actual' },
      { id: 'forecast', label: 'Forecast (9 + 3)', kind: 'forecast', actuals: 'actual' },
    ],
    values: { budget, actual, forecast },
    profiles: [{ id: 'season', label: 'Year-end weighted', weights: season }],
    status: { marketing: 'submitted' },
    comments: [{ id: 'c1', version: 'forecast', row: 'licences', period: 'Q4', text: 'Two enterprise renewals moved into January.', at: '2026-10-09T10:30:00Z' }],
  };
  await grid.selectCell('licences', '2026-10');
  grid.addEventListener('bmxChange', event => {
    const n = event.detail.changes.length;
    out.textContent = n ? `${n} cell${n === 1 ? '' : 's'} changed (${event.detail.reason}). The page would save event.detail.changes.` : `Plan updated (${event.detail.reason}).`;
  });
</script>
<bmx-planning-grid id="plan" scale="thousands"></bmx-planning-grid>
<script>
  plan.plan = {
    currency: 'GBP',
    periods: [{ id: '2027-01', label: 'Jan', group: 'Q1' }, ...],
    rows: [
      { id: 'revenue', label: 'Revenue' },
      { id: 'licences', label: 'Licences', parent: 'revenue' },
      { id: 'costs', label: 'Costs', favourable: 'down' },
      { id: 'margin', label: 'Margin', formula: 'revenue - costs' },
    ],
    versions: [{ id: 'budget', label: 'Budget' }, { id: 'actual', label: 'Actual', kind: 'actual' }],
    values: { budget: { licences: [120000, ...] } },
  };
  plan.addEventListener('bmxChange', e => save(e.detail.changes));
</script>

Type into a month, or type a quarter's or a year's total - or a group's - and it is spread over the cells below it: in proportion to what is there, evenly, by a seasonality profile, or the same number in each. Held cells, closed periods and signed-off rows keep their numbers. +10% grows what is there; +=500 adds to it; 1.2k and 3m are understood.

Group rows add up their children (each adding, subtracting or left out); formula rows are worked out per period in the library's spreadsheet formulas with row ids as names, plus PRIOR(row) and YTD(row); quarter and year totals follow each row's aggregate, and a ratio's total is worked out again from the totals. A version can take its closed periods from Actual (a rolling forecast). Compare with another version to see the difference in every cell, coloured by whether it is good news for the row.

What-if lets a planner try changes and see their effect before applying or discarding them. Comments, holds and sign-off are kept in the plan. Copy and paste work with spreadsheets. Every change is handed to the page (bmxChange, getChanges()); nothing is stored or sent anywhere.

Properties

PropertyAttributeTypeDefaultDescription
compare compare string — Another version to compare every cell with. Two-way.
currency currency string — ISO 4217 code for currency rows. Default: the plan's, then USD.
label label string — The grid's accessible name. Default: the plan's name, or "Plan".
locale locale string — A BCP 47 locale for numbers and money. Default: the page's lang, then the reader's.
negative negative BmxPlanNegative 'minus' How negative numbers are shown.
panel panel BmxPlanningGridPanel 'details' The panel beside the grid.
plan plan BmxPlanData | string { periods: [], rows: [], versions: [], values: {} } The plan: periods, rows, versions, values, holds, comments and sign-off (object or JSON). Two-way.
readonly readonly boolean false Shows the plan without letting it be changed.
scale scale BmxPlanScale 'units' Numbers shown in units, thousands or millions; typed numbers are read in the same scale. Two-way.
spreadMethod spread-method BmxPlanSpreadMethod 'proportional' How a total typed into a group row or a total column is spread.
strings strings Partial<BmxPlanningGridStrings> | string — Words to show instead of the English ones, as an object or JSON.
totals totals BmxPlanTotals 'both' Which totals are shown: group (quarter) totals, the grand total, both or none.
user user string — The name new comments are signed with.
version version string — The version shown and edited. Default: the first. Two-way.

Events

EventDetailDescription
bmxChange BmxPlanChangeDetail Fired after every change to the plan, with the whole plan and the cells that changed.
bmxSelect BmxPlanSelectDetail Fired when the active cell moves.

Methods

MethodSignatureDescription
acceptChanges acceptChanges() => Promise<void> Marks the current numbers as saved: getChanges() starts again from here.
addComment addComment(row: string, column: string, text: string, version?: string) => Promise<void> Adds a comment to a cell; column is a period id, a group's name or total.
applyWhatIf applyWhatIf() => Promise<void> Keeps the what-if's changes as one change to the plan.
calculate calculate(version?: string) => Promise<{ rows: Record<string, (number | null)[]>; errors: Record<string, (string | null)[]>; }> The plan worked out for a version: every row's period values, and totals by result.total(row, from, to).
check check() => Promise<BmxPlanProblem[]> Problems with the plan's ids, parents and formulas.
collapseAll collapseAll() => Promise<void> Shows only the top rows.
discardWhatIf discardWhatIf() => Promise<void> Throws the what-if's changes away.
expandAll expandAll() => Promise<void> Shows every row.
getChanges getChanges() => Promise<BmxPlanChange[]> Every cell changed since the plan was loaded or acceptChanges() was last called.
getCsv getCsv(version?: string) => Promise<string> The plan as CSV: every row, the periods and totals, unformatted numbers, for one version.
getPlan getPlan() => Promise<BmxPlanData> The plan as it stands (without an unapplied what-if).
getValue getValue(row: string, column: string, version?: string) => Promise<number | null> One number: a period's, a group's (by its name) or the year's (total). Default version: the one shown.
hold hold(row: string, period: string, held?: boolean, version?: string) => Promise<void> Holds or releases a period cell, so spreading leaves it as it is.
redo redo() => Promise<boolean> Redoes the last undone change.
selectCell selectCell(row: string, column: string) => Promise<boolean> Makes a cell the active one and scrolls it into view (without moving focus); column is a period id, a group's name or total.
setPlan setPlan(plan: BmxPlanData | string, options?: { keepHistory?: boolean; }) => Promise<void> Replaces the plan. keepHistory keeps undo.
setStatus setStatus(row: string, status: BmxPlanStatus) => Promise<void> Sets a row's sign-off. Submitted and approved rows, and the rows below them, cannot be edited.
setValue setValue(row: string, column: string, value: number | string | null, version?: string) => Promise<boolean> Sets a number as if typed: a period cell takes it; a group row or a total spreads it. Resolves whether anything changed.
spread spread(row: string, column: string, total: number, options?: { method?: BmxPlanSpreadMethod; profile?: string; version?: string; }) => Promise<BmxPlanSpreadResult> Spreads a total over the cells below a row in a column (period, group name or total).
startWhatIf startWhatIf() => Promise<void> Starts a what-if: changes are tried out and shown against the plan until applied or discarded.
undo undo() => Promise<boolean> Undoes the last change. Resolves whether there was one.

CSS shadow parts

PartDescription
cell A number cell; also cell-input, cell-total, cell-held, cell-actual, cell-error.
details The cell details panel.
dialog The spread dialog.
grid The scrolling grid.
row A row of the grid.
toolbar The row of version, comparison and actions.

CSS custom properties

PropertyDescription
--bmx-planning-grid-actual The background of cells holding actuals.
--bmx-planning-grid-cell-width The width of each number column.
--bmx-planning-grid-favourable The colour of good-news differences.
--bmx-planning-grid-head-width The width of the row headings column.
--bmx-planning-grid-held The colour that marks a held cell.
--bmx-planning-grid-input The background of cells that can be typed into.
--bmx-planning-grid-max-height The tallest the grid grows before it scrolls.
--bmx-planning-grid-total The background of totals and group rows.
--bmx-planning-grid-unfavourable The colour of bad-news differences.