Lattice Grid

blog

This Quarter vs Last Quarter: a Grid That Diffs the Two

Building…
Loading a live grid…

Whoever builds the quarterly business review pulls this quarter’s number from the live orders grid easily enough, then goes looking for last quarter’s, which by now only exists as a spreadsheet exported the week that quarter closed. The two sit side by side on the slide until someone in the room asks whether the swing holds for EMEA alone, or for one rep’s patch, and the exported half cannot answer, because it was flattened to a single number before the question existed. A period over period comparison in JavaScript can run the same way the two grids either side of it do: off live data, not off a number pasted in before anyone asked a follow-up.

A derived grid turns the comparison into configuration instead of an export. Split the orders grid into two more derived grids, one reading this quarter’s rows and one reading last quarter’s, then point a third grid at both: it ranks every shared column by how much it moved between them, using an effect size rather than eyeballing two totals. Filter the orders grid to EMEA, or to one rep, and all three follow, because each reads the one underlying grid rather than a copy frozen at quarter end.

That removes the export step from the review entirely. Nobody re-pulls last quarter’s numbers to check the comparison still holds, because last quarter’s grid was never a snapshot, it is still reading live off the same orders. Twenty orders split into two quarters here, and the swing on amount comes out the same whether the room asks about EMEA or not.

Period over period comparison in JavaScript, from two grids

import { createGrid } from '@toclocoinc/lattice-grid';

const orders = [
  { id: 1,  date: '2026-04-06', region: 'EMEA',     amount: 420 },
  { id: 2,  date: '2026-04-14', region: 'APAC',     amount: 180 },
  { id: 3,  date: '2026-04-21', region: 'Americas', amount: 560 },
  { id: 4,  date: '2026-05-02', region: 'EMEA',     amount: 310 },
  { id: 5,  date: '2026-05-09', region: 'Americas', amount: 340 },
  { id: 6,  date: '2026-05-18', region: 'APAC',     amount: 275 },
  { id: 7,  date: '2026-05-27', region: 'EMEA',     amount: 490 },
  { id: 8,  date: '2026-06-04', region: 'Americas', amount: 250 },
  { id: 9,  date: '2026-06-15', region: 'APAC',     amount: 410 },
  { id: 10, date: '2026-06-28', region: 'EMEA',     amount: 220 },
  { id: 11, date: '2026-07-03', region: 'Americas', amount: 730 },
  { id: 12, date: '2026-07-12', region: 'APAC',     amount: 395 },
  { id: 13, date: '2026-07-24', region: 'EMEA',     amount: 605 },
  { id: 14, date: '2026-08-02', region: 'Americas', amount: 540 },
  { id: 15, date: '2026-08-11', region: 'APAC',     amount: 515 },
  { id: 16, date: '2026-08-20', region: 'EMEA',     amount: 675 },
  { id: 17, date: '2026-09-05', region: 'Americas', amount: 820 },
  { id: 18, date: '2026-09-15', region: 'APAC',     amount: 460 },
  { id: 19, date: '2026-09-22', region: 'EMEA',     amount: 450 },
  { id: 20, date: '2026-09-29', region: 'Americas', amount: 590 },
];

const book = createGrid(document.querySelector('#orders'), {
  rowKey: 'id',
  columns: [
    { field: 'id',     title: 'Order' },
    { field: 'date',   title: 'Date', type: 'date' },
    { field: 'region', title: 'Region', filter: { type: 'set' } },
    { field: 'amount', title: 'Amount', type: 'number' },
  ],
  rows: orders,
});

// A fresh columns array for each quarter grid, not a reference borrowed
// from book's own config.
const quarterColumns = [
  { field: 'id',     title: 'Order' },
  { field: 'date',   title: 'Date', type: 'date' },
  { field: 'region', title: 'Region', filter: { type: 'set' } },
  { field: 'amount', title: 'Amount', type: 'number' },
];

const thisQuarter = createGrid(document.querySelector('#this-quarter'), {
  rowKey: 'id',
  columns: quarterColumns,
  source: {
    mode: 'derived',
    from: book,
    follow: 'filtered',
    where: (r) => r.date >= '2026-07-01' && r.date < '2026-10-01',
  },
});

const lastQuarter = createGrid(document.querySelector('#last-quarter'), {
  rowKey: 'id',
  columns: quarterColumns,
  source: {
    mode: 'derived',
    from: book,
    follow: 'filtered',
    where: (r) => r.date >= '2026-04-01' && r.date < '2026-07-01',
  },
});

// A terminal producer, not a group by: one row per compared column.
const compare = createGrid(document.querySelector('#compare'), {
  rowKey: 'column',
  columns: [
    { field: 'column',    title: 'Column' },
    { field: 'magnitude',  title: "Effect size", type: 'number' },
    { field: 'direction',  title: 'Direction' },
    { field: 'nA',         title: 'This quarter', type: 'number' },
    { field: 'nB',         title: 'Last quarter',  type: 'number' },
  ],
  source: {
    mode: 'derived',
    from: thisQuarter,
    statistics: { fn: 'datasetVsDataset', with: lastQuarter, columns: ['amount'] },
  },
});

Filter the orders grid to EMEA and thisQuarter, lastQuarter and compare all narrow to EMEA’s rows, because none of the three holds a copy of anything, they read the orders grid and each other. where on the two quarter grids runs before anything else; statistics on the comparison grid is a terminal producer with no groupBy or select beside it, because it replaces that pipeline rather than running alongside it.

React

import React from 'react';
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { createLatticeGrid } from '@toclocoinc/lattice-grid/modules/react';

// Build the component once, at module scope, not on every render.
const LatticeGrid = createLatticeGrid({ React, createGrid });

const quarterColumns = [
  { field: 'id',     title: 'Order' },
  { field: 'date',   title: 'Date', type: 'date' },
  { field: 'region', title: 'Region', filter: { type: 'set' } },
  { field: 'amount', title: 'Amount', type: 'number' },
];

const compareColumns = [
  { field: 'column',   title: 'Column' },
  { field: 'magnitude', title: 'Effect size', type: 'number' },
  { field: 'direction', title: 'Direction' },
  { field: 'nA',        title: 'This quarter', type: 'number' },
  { field: 'nB',        title: 'Last quarter',  type: 'number' },
];

function QuarterOverQuarter({ orders }) {
  const [book, setBook] = React.useState(null);
  const [thisQuarter, setThisQuarter] = React.useState(null);
  const [lastQuarter, setLastQuarter] = React.useState(null);

  return (
    <>
      <LatticeGrid
        rowKey="id"
        columns={quarterColumns}
        rows={orders}
        onGridReady={setBook}
        style={{ height: 320 }}
      />
      {book && (
        <LatticeGrid
          rowKey="id"
          columns={quarterColumns}
          source={{
            mode: 'derived',
            from: book,
            follow: 'filtered',
            where: (r) => r.date >= '2026-07-01' && r.date < '2026-10-01',
          }}
          onGridReady={setThisQuarter}
          style={{ height: 220 }}
        />
      )}
      {book && (
        <LatticeGrid
          rowKey="id"
          columns={quarterColumns}
          source={{
            mode: 'derived',
            from: book,
            follow: 'filtered',
            where: (r) => r.date >= '2026-04-01' && r.date < '2026-07-01',
          }}
          onGridReady={setLastQuarter}
          style={{ height: 220 }}
        />
      )}
      {thisQuarter && lastQuarter && (
        <LatticeGrid
          rowKey="column"
          columns={compareColumns}
          source={{
            mode: 'derived',
            from: thisQuarter,
            statistics: { fn: 'datasetVsDataset', with: lastQuarter, columns: ['amount'] },
          }}
          style={{ height: 140 }}
        />
      )}
    </>
  );
}

The comparison panel waits for both quarter instances, the same way the quarter panels wait for book, because statistics.with needs a real grid to read, the same as from does. Filter the orders grid from its own header and every panel downstream narrows with it, through the same public API the plain example reads.

The options used

Option What it does
mode: 'derived' This grid’s rows are computed from another grid’s, not loaded from an array or a server.
from The grid to read.
follow: 'filtered' Read the rows the filters leave. The default.
where A row predicate, applied before the rest of the pipeline runs.
statistics.fn: 'datasetVsDataset' Selects the dataset-comparison producer: one row per compared column, against a second grid. A terminal producer, so groupBy, select, sort and limit have no place alongside it.
statistics.with The second grid to compare this one against.
statistics.columns Restrict the comparison to these columns. All shared columns by default.

What the example actually produced

Running the plain JavaScript example above with createHeadlessGrid from the published package, and printing compare’s rows, gives:

Column Effect size Direction This quarter Last quarter
amount 1.91 this quarter higher 10 10

Ten orders a side, an effect size of 1.91 on amount, and the direction says the swing runs upward: this quarter’s total of 5,780 against last quarter’s 3,455. Filter either quarter grid to EMEA and the comparison re-reads only EMEA’s rows on both sides, so the same question the room asks after the slide goes up has an answer already live on the screen.

The derived and chained grids guide covers statistics alongside correlation, column profiles and chaining a derived grid from a derived grid in full. For a derived grid that buckets by month instead of splitting by quarter, see the monthly revenue trend grid.

Read next

All posts RSS