# Aggregate

> Package `@umriss-ui/table`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/table/aggregate/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/table/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/table/llms-full.txt).

What a column's values come to: a sum, an average, the extremes, a count. It stands in the footer over the filtered rows and in each group header over the group's rows.

```ts
import { useTable } from "@umriss-ui/table";
```

Demo page: https://romanhaendler.github.io/umriss-ui/table/aggregate/

An aggregate is always computed from rows: a group's average is over its rows, not over the averages of the groups inside it. Absent values do not count.

A rate or a weighted average is the application's to say, with an aggregate of its own.

## Examples

### Total a column

`aggregate="sum"` and `"avg"` fill the footer over what search and filters leave; an average counts only the values present.

```tsx
import { Search, Toolbar, useTable } from "@umriss-ui/table";

interface Tour {
  id: string;
  depot: string;
  km: number;
  stops: number;
  load: number | null;
}

const TOURS: Tour[] = [
  { id: "T-01", depot: "North depot", km: 84, stops: 11, load: 0.72 },
  { id: "T-02", depot: "North depot", km: 91, stops: 9, load: 0.64 },
  { id: "T-03", depot: "North depot", km: 152, stops: 5, load: 0.88 },
  { id: "T-04", depot: "Riverside depot", km: 77, stops: 10, load: null },
  { id: "T-05", depot: "Riverside depot", km: 102, stops: 8, load: 0.58 },
];

export default function SumAndAverage() {
  const { Table, Column } = useTable(TOURS, { rowKey: (t) => t.id });
  return (
    <Table ariaLabel="Tours">
      <Toolbar>
        <Search placeholder="Search depot" />
      </Toolbar>
      <Column value="id" label="Tour" rowHeader />
      <Column value="depot" label="Depot" />
      <Column value="km" label="Distance (km)" aggregate="sum" />
      <Column value="stops" label="Stops" aggregate="sum" />
      <Column value="load" label="Load" format="percent" aggregate="avg" />
    </Table>
  );
}
```

### Find the extremes

`"min"` and `"max"` take the extremes of numbers and dates alike, written in the column's format.

```tsx
import { useTable } from "@umriss-ui/table";

interface Incident {
  id: string;
  toAcknowledge: number;
  opened: Date;
}

const at = (day: number, hours: number, minutes: number) => new Date(2026, 2, day, hours, minutes);

const INCIDENTS: Incident[] = [
  { id: "INC-1048", toAcknowledge: 4, opened: at(17, 9, 42) },
  { id: "INC-1047", toAcknowledge: 47, opened: at(17, 7, 15) },
  { id: "INC-1046", toAcknowledge: 6, opened: at(16, 22, 5) },
  { id: "INC-1045", toAcknowledge: 4, opened: at(16, 14, 20) },
  { id: "INC-1044", toAcknowledge: 1392, opened: at(15, 10, 0) },
];

export default function MinimumAndMaximum() {
  const { Table, Column } = useTable(INCIDENTS, { rowKey: (i) => i.id });
  return (
    <Table ariaLabel="Time to acknowledge">
      <Column value="id" label="Incident" rowHeader />
      <Column value="toAcknowledge" label="Minutes to acknowledge" aggregate="max" />
      <Column value="opened" label="Opened" format="dateTime" aggregate="min" />
    </Table>
  );
}
```

### Show a range of dates

`"range"` gives the earliest and latest date of each group, so a project's header says from when to when its work runs.

```tsx
import { useTable } from "@umriss-ui/table";

interface WorkItem {
  id: string;
  project: string;
  person: string;
  name: string;
  starts: Date;
}

const day = (d: number) => new Date(2026, 2, d);

const WORK: WorkItem[] = [
  { id: "W-101", project: "Member portal", person: "Arjun Mehta", name: "Sign-in with e-mail code", starts: day(9) },
  { id: "W-102", project: "Member portal", person: "Arjun Mehta", name: "Profile page", starts: day(12) },
  { id: "W-105", project: "Member portal", person: "Chloe Durand", name: "Change of address form", starts: day(16) },
  { id: "W-109", project: "Member portal", person: "Eva Novak", name: "Regression run", starts: day(17) },
  { id: "W-104", project: "Online shop relaunch", person: "Chloe Durand", name: "Basket keeps items across devices", starts: day(11) },
  { id: "W-107", project: "Online shop relaunch", person: "Noah Fischer", name: "Search results layout", starts: day(16) },
  { id: "W-110", project: "Booking app", person: "Hana Sato", name: "Reminder scheduling service", starts: day(9) },
  { id: "W-115", project: "Booking app", person: "Freya Olsen", name: "Store screenshots", starts: day(18) },
];

export default function RangeOfDates() {
  const { Table, Column } = useTable(WORK, { rowKey: (w) => w.id, defaultGrouping: ["project", "person"] });
  return (
    <Table ariaLabel="Work items by project and person">
      <Column value="person" label="Person" />
      <Column value="project" label="Project" />
      <Column value="id" label="Item" rowHeader />
      <Column value="name" label="Title" />
      <Column value="starts" label="Starts" format="date" aggregate="range" />
    </Table>
  );
}
```

### Count values and distinct values

`"count"` counts the values present, `"distinct"` the different ones: eight alerts, but three services and two people assigned.

```tsx
import { useTable } from "@umriss-ui/table";

interface Alert {
  id: string;
  service: string;
  assignee: string | null;
  minutes: number;
}

const ALERTS: Alert[] = [
  { id: "A-2201", service: "Checkout", assignee: "Jonas Keller", minutes: 18 },
  { id: "A-2202", service: "Checkout", assignee: "Jonas Keller", minutes: 42 },
  { id: "A-2203", service: "Search", assignee: "Leila Haddad", minutes: 21 },
  { id: "A-2204", service: "Search", assignee: null, minutes: 7 },
  { id: "A-2205", service: "Webhooks", assignee: null, minutes: 35 },
  { id: "A-2206", service: "Checkout", assignee: "Leila Haddad", minutes: 16 },
  { id: "A-2207", service: "Webhooks", assignee: null, minutes: 25 },
  { id: "A-2208", service: "Search", assignee: "Jonas Keller", minutes: 4 },
];

export default function CountAndDistinct() {
  const { Table, Column } = useTable(ALERTS, { rowKey: (a) => a.id });
  return (
    <Table ariaLabel="Alerts this morning">
      <Column value="id" label="Alert" rowHeader aggregate="count" />
      <Column value="service" label="Service" aggregate="distinct" />
      <Column value="assignee" label="Assignee" aggregate="distinct" />
      <Column value="minutes" label="Minutes open" aggregate="sum" />
    </Table>
  );
}
```

### Show each group's share

Under a group's sum stands a thin bar for its share of the whole; `share={false}` takes it away where a share says nothing.

```tsx
import { useTable } from "@umriss-ui/table";

interface Invoice {
  id: string;
  costCentre: string;
  supplier: string;
  amount: number;
  lines: number;
}

const INVOICES: Invoice[] = [
  { id: "INV-26-0318", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 1951.24, lines: 3 },
  { id: "INV-26-0312", costCentre: "Facilities", supplier: "Hartwell Cleaning", amount: 2400, lines: 1 },
  { id: "INV-26-0301", costCentre: "Facilities", supplier: "Brandlow Office Supply", amount: 860, lines: 1 },
  { id: "INV-26-0317", costCentre: "Marketing", supplier: "Kettering & Shaw Events", amount: 15_110, lines: 2 },
  { id: "INV-26-0296", costCentre: "Marketing", supplier: "Lumen Print", amount: 1940, lines: 4 },
  { id: "INV-26-0309", costCentre: "IT", supplier: "Nimbrel Software", amount: 7288, lines: 2 },
];

export default function ShareBar() {
  const { Table, Column } = useTable(INVOICES, { rowKey: (i) => i.id, defaultGrouping: ["costCentre", "supplier"] });
  return (
    <Table ariaLabel="Invoices by cost centre and supplier">
      <Column value="supplier" label="Supplier" />
      <Column value="costCentre" label="Cost centre" />
      {/* The amount with its bar right beside the groups: after the invoice
          it stood out of view on a phone. */}
      <Column value="amount" label="Amount (€)" format={{ decimals: 2 }} aggregate="sum" />
      <Column value="id" label="Invoice" rowHeader />
      <Column value="lines" label="Lines" aggregate="sum" share={false} />
    </Table>
  );
}
```

### Weight a rate by its rows

Averaging rates weighs a small tour like a large one; divide the sums with an aggregate of your own, and header and footer are right.

```tsx
import { useTable } from "@umriss-ui/table";

interface Tour {
  id: string;
  depot: string;
  parcels: number;
  failed: number;
}

const TOURS: Tour[] = [
  { id: "T-01", depot: "North depot", parcels: 410, failed: 5 },
  { id: "T-02", depot: "North depot", parcels: 380, failed: 3 },
  { id: "T-03", depot: "North depot", parcels: 12, failed: 3 },
  { id: "T-04", depot: "Riverside depot", parcels: 520, failed: 6 },
  { id: "T-05", depot: "Riverside depot", parcels: 460, failed: 4 },
];

const rate = (t: Tour) => t.failed / t.parcels;

export default function NeverAnAverageOfAverages() {
  const { Table, Column } = useTable(TOURS, { rowKey: (t) => t.id, defaultGrouping: "depot" });
  return (
    <Table ariaLabel="Failed deliveries by depot">
      <Column value="depot" label="Depot" />
      <Column value="id" label="Tour" rowHeader />
      <Column value="parcels" label="Parcels" format="count" aggregate="sum" share={false} />
      <Column value="failed" label="Failed" aggregate="sum" share={false} />
      <Column id="rateAveraged" value={rate} label="Rate, averaged" format="percent" aggregate="avg" />
      <Column
        id="rate"
        value={rate}
        label="Rate, weighted"
        format="percent"
        aggregate={(_, tours) => tours.reduce((s, t) => s + t.failed, 0) / tours.reduce((s, t) => s + t.parcels, 0)}
      />
    </Table>
  );
}
```

### Write an aggregate of your own

A function gets the values present and the group's rows and returns a value of the column's type; here a unit price weighted by quantity.

```tsx
import { useTable } from "@umriss-ui/table";

interface Line {
  id: string;
  costCentre: string;
  description: string;
  quantity: number;
  unitPrice: number;
}

const LINES: Line[] = [
  { id: "L-1", costCentre: "Facilities", description: "Desk lamps, LED", quantity: 24, unitPrice: 48.9 },
  { id: "L-2", costCentre: "Facilities", description: "Printer paper, box", quantity: 40, unitPrice: 21.5 },
  { id: "L-3", costCentre: "Facilities", description: "Delivery", quantity: 1, unitPrice: 35 },
  { id: "L-4", costCentre: "IT", description: "Design tool licences", quantity: 12, unitPrice: 459 },
  { id: "L-5", costCentre: "IT", description: "Onboarding session", quantity: 2, unitPrice: 890 },
];

export default function OwnAggregate() {
  const { Table, Column } = useTable(LINES, { rowKey: (l) => l.id, defaultGrouping: "costCentre" });
  return (
    <Table ariaLabel="Invoice lines by cost centre">
      <Column value="costCentre" label="Cost centre" />
      <Column value="description" label="Description" rowHeader />
      <Column value="quantity" label="Quantity" aggregate="sum" />
      <Column
        value="unitPrice"
        label="Unit price (€)"
        format={{ decimals: 2 }}
        aggregate={(_, lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0) / lines.reduce((s, l) => s + l.quantity, 0)}
      />
    </Table>
  );
}
```

### Carry the worst verdict up

On a `VerdictColumn`, `aggregate="worst"` shows the worst verdict among a group's rows; fold a zone and its line still shows the alarm.

```tsx
import type { LimitSet } from "@umriss-ui/core";
import { useTable } from "@umriss-ui/table";

/* Data from the plant world, written out here so the example runs on its own. */
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
    Between the two warnings lies the tolerance - a tile fired outside it is
    scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;

/** The same numbers as the limit set the tile and the verdict read. */
const KILN_LIMITS: LimitSet = {
  target: KILN.target,
  limits: [
    { value: KILN.tolerance[1], side: "upper", severity: "warning" },
    { value: KILN.alarm, side: "upper", severity: "alarm" },
    { value: KILN.tolerance[0], side: "lower", severity: "warning" },
  ],
};

interface Reading {
  id: string;
  kiln: string;
  zone: string;
  at: string;
  temperature: number | null;
}

const READINGS: Reading[] = [
  { id: "Z1-0600", kiln: "Kiln K1", zone: "Zone 1", at: "06:00", temperature: 1198.4 },
  { id: "Z1-0700", kiln: "Kiln K1", zone: "Zone 1", at: "07:00", temperature: 1201.2 },
  { id: "Z1-0800", kiln: "Kiln K1", zone: "Zone 1", at: "08:00", temperature: 1203.9 },
  { id: "Z2-0600", kiln: "Kiln K1", zone: "Zone 2", at: "06:00", temperature: 1209.5 },
  { id: "Z2-0700", kiln: "Kiln K1", zone: "Zone 2", at: "07:00", temperature: 1217.8 },
  { id: "Z2-0800", kiln: "Kiln K1", zone: "Zone 2", at: "08:00", temperature: 1212 },
  { id: "Z3-0600", kiln: "Kiln K1", zone: "Zone 3", at: "06:00", temperature: 1224.1 },
  { id: "Z3-0700", kiln: "Kiln K1", zone: "Zone 3", at: "07:00", temperature: 1233.6 },
  { id: "Z3-0800", kiln: "Kiln K1", zone: "Zone 3", at: "08:00", temperature: null },
];

export default function WorstVerdict() {
  const { Table, Column, VerdictColumn } = useTable(READINGS, { rowKey: (r) => r.id, defaultGrouping: ["kiln", "zone"] });
  return (
    <Table ariaLabel="Kiln temperature by zone">
      <Column value="zone" label="Zone" />
      <Column value="kiln" label="Kiln" />
      <Column value="at" label="Hour" rowHeader />
      <VerdictColumn value="temperature" label="Temperature (°C)" limits={KILN_LIMITS} format={{ decimals: 1 }} aggregate="worst" />
    </Table>
  );
}
```

## When to use something else

- A key figure outside the table → `Stat` from @umriss-ui/core

## Keyboard

The keys of [First table](https://romanhaendler.github.io/umriss-ui/table/first-table/#keyboard-first-table) and [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input) apply here.

## API

### `AggregateOptions<W, Z>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `aggregate` | [`AggregateFor`](https://romanhaendler.github.io/umriss-ui/table/aggregate/#type-AggregateFor)`<W, Z>` | — | What the column's values come to: in the footer over the filtered set, in a group's header over its rows. `"sum"`, `"avg"` for numbers; `"min"`, `"max"` for numbers and points in time; `"range"` for points in time; `"count"`, `"distinct"` for every value with a text form - or a function of one's own. Always from the values, never from other aggregates; absent values count towards nothing.<br>Shown in: [Total a column](https://romanhaendler.github.io/umriss-ui/table/aggregate/#sum-and-average), [Find the extremes](https://romanhaendler.github.io/umriss-ui/table/aggregate/#minimum-and-maximum), [Show a range of dates](https://romanhaendler.github.io/umriss-ui/table/aggregate/#range-of-dates) and 34 more |
| `share` | `boolean` | — | The share bar in a group's header – the group's share of the filtered set's sum. It stands under the first column with a sum, one per header; `false` passes it on to the next.<br>Shown in: [Show each group's share](https://romanhaendler.github.io/umriss-ui/table/aggregate/#share-bar), [Weight a rate by its rows](https://romanhaendler.github.io/umriss-ui/table/aggregate/#never-an-average-of-averages), [Share a column between tables (Presets)](https://romanhaendler.github.io/umriss-ui/table/presets/#presets) and 2 more |

### Types on this page

#### `AggregateFor<W, Z>`

What a column accepts as `aggregate`: sums and averages for numbers, the
extremes for numbers and points in time, the range for points in time,
counts for every value with a text form - or a function of one's own.

```ts
type AggregateFor<W, Z> =
  | ([Present<W>] extends [number] ? "sum" | "avg" | "min" | "max" : never)
  | ([Present<W>] extends [Date] ? "min" | "max" | "range" : never)
  | (IsDisplayable<W> extends true ? "count" | "distinct" : never)
  | AggregateFunction<W, Z>;
```

#### `Present<W>`

The value without the absence - that is what `children` gets.

```ts
type Present<W> = Exclude<W, Absent>;
```

#### `IsDisplayable<W>`

`true` where the value present can appear as text without `children`
(`Displayable`), `false` otherwise - what decides the filters and
aggregates a column offers.

```ts
type IsDisplayable<W> = [Present<W>] extends [never]
  ? false
  : [Present<W>] extends [Displayable]
    ? true
    : false;
```

#### `AggregateFunction<W, Z>`

An aggregate of one's own: the values present and the rows of the group -
or of the filtered set, in the footer. Its result runs through the column's
presentation; `undefined` means there is none.

```ts
type AggregateFunction<W, Z> = (values: readonly Present<NoInfer<W>>[], rows: readonly Z[]) => Present<NoInfer<W>> | undefined;
```

#### `Absent`

A value that is not there: `null` or `undefined`.

```ts
type Absent = null | undefined;
```

#### `Displayable`

What can appear as text without `children`.

```ts
type Displayable = string | number | boolean | Date;
```

## Known limits

- No footer in [server mode](https://romanhaendler.github.io/umriss-ui/table/server-mode/): a sum over one page would misstate the server's set.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
