Repository: paularmstrong/build-tracker
Pull request #36 (2019-03-05 00:39:42)

Budgets
# Problem

Budgeting is one of the core features that Build Tracker intends to empower developers with, but they are currently not configurable.

# Solution


# TODO

- [ ] Explain the solution
- [ ] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation



Patch:
diff --git a/src/app/src/components/ComparisonTable/ArtifactCell.tsx b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
index 30e7067..64ecfc0 100644
--- a/src/app/src/components/ComparisonTable/ArtifactCell.tsx
+++ b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
@@ -2,14 +2,14 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../../theme';
-import { ArtifactCell as Cell } from '@build-tracker/comparator';
 import { hsl } from 'd3-color';
 import React from 'react';
 import { Th } from './../Table';
+import { ArtifactCell as ACell, GroupCell as GCell } from '@build-tracker/comparator';
 import { StyleProp, StyleSheet, Switch, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
-  cell: Cell;
+  cell: ACell | GCell;
   color: string;
   disabled?: boolean;
   isActive: boolean;
diff --git a/src/app/src/components/ComparisonTable/BodyRow.tsx b/src/app/src/components/ComparisonTable/BodyRow.tsx
index d4ce2e5..7601998 100644
--- a/src/app/src/components/ComparisonTable/BodyRow.tsx
+++ b/src/app/src/components/ComparisonTable/BodyRow.tsx
@@ -8,9 +8,16 @@ import React from 'react';
 import { ScaleSequential } from 'd3-scale';
 import { StyleSheet } from 'react-native';
 import TotalCell from './TotalCell';
-import TotalDeltaCell from './TotalDeltaCell';
 import { Tr } from './../Table';
-import { BodyCell, CellType, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
+import {
+  ArtifactCell as ACell,
+  ArtifactRow,
+  CellType,
+  DeltaCell as DCell,
+  GroupRow,
+  TotalCell as TCell,
+  TotalDeltaCell as TDCell
+} from '@build-tracker/comparator';
 
 interface Props {
   colorScale: ScaleSequential<string>;
@@ -19,7 +26,7 @@ interface Props {
   onDisableArtifact: (artifactName: string) => void;
   onEnableArtifact: (artifactName: string) => void;
   onHoverArtifact: (revision: string) => void;
-  row: Array<BodyCell | TDCell>;
+  row: ArtifactRow | GroupRow;
   rowIndex: number;
   sizeKey: string;
 }
@@ -37,7 +44,7 @@ export const BodyRow = (props: Props): React.ReactElement => {
     sizeKey
   } = props;
 
-  const mapBodyCell = (cell: BodyCell | TDCell, i: number): React.ReactElement | void => {
+  const mapBodyCell = (cell: ACell | TCell | TDCell | DCell, i: number): React.ReactElement | void => {
     switch (cell.type) {
       case CellType.ARTIFACT: {
         return (
@@ -55,14 +62,11 @@ export const BodyRow = (props: Props): React.ReactElement => {
         return <DeltaCell cell={cell} key={i} sizeKey={sizeKey} />;
       case CellType.TOTAL:
         return <TotalCell cell={cell} key={i} sizeKey={sizeKey} />;
-      case CellType.TOTAL_DELTA:
-        return <TotalDeltaCell cell={cell} key={i} sizeKey={sizeKey} />;
     }
   };
 
   let backgroundColor = 'transparent';
-  // @ts-ignore
-  const artifactName = 'text' in row[0] && row[0].text;
+  const artifactName = row[0].text;
   if (isHovered) {
     const color = hsl(colorScale(rowIndex));
     color.l = 0.9;
@@ -72,9 +76,12 @@ export const BodyRow = (props: Props): React.ReactElement => {
     onHoverArtifact(isActive && artifactName !== 'All' ? artifactName : null);
   }, [artifactName, isActive, onHoverArtifact]);
 
+  // @ts-ignore
+  const rows = row.map(mapBodyCell);
+
   return (
     <Tr onMouseEnter={handleMouseEnter} style={[styles.row, { backgroundColor }]}>
-      {row.map(mapBodyCell)}
+      {rows}
     </Tr>
   );
 };
diff --git a/src/app/src/components/ComparisonTable/ComparisonTable.tsx b/src/app/src/components/ComparisonTable/ComparisonTable.tsx
index ad9dc48..8e4e4e4 100644
--- a/src/app/src/components/ComparisonTable/ComparisonTable.tsx
+++ b/src/app/src/components/ComparisonTable/ComparisonTable.tsx
@@ -3,6 +3,7 @@
  */
 import BodyRow from './BodyRow';
 import Comparator from '@build-tracker/comparator';
+import GroupRow from './GroupRow';
 import HeaderRow from './HeaderRow';
 import React from 'react';
 import { ScaleSequential } from 'd3-scale';
@@ -14,11 +15,11 @@ interface Props {
   colorScale: ScaleSequential<string>;
   comparator: Comparator;
   disabledArtifactsVisible: boolean;
-  hoveredArtifact: string;
-  onDisableArtifact: (artifactName: string) => void;
-  onEnableArtifact: (artifactName: string) => void;
+  hoveredArtifacts: Array<string>;
+  onDisableArtifacts: (artifactNames: Array<string>) => void;
+  onEnableArtifacts: (artifactNames: Array<string>) => void;
   onFocusRevision: (revision: string) => void;
-  onHoverArtifact: (revision: string) => void;
+  onHoverArtifacts: (artifactNames: Array<string>) => void;
   onRemoveRevision: (revision: string) => void;
   sizeKey: string;
 }
@@ -28,11 +29,11 @@ const ComparisonTable = (props: Props): React.ReactElement => {
     activeArtifacts,
     comparator,
     disabledArtifactsVisible,
-    hoveredArtifact,
-    onDisableArtifact,
-    onEnableArtifact,
+    hoveredArtifacts,
+    onDisableArtifacts,
+    onEnableArtifacts,
     onFocusRevision,
-    onHoverArtifact,
+    onHoverArtifacts,
     onRemoveRevision,
     sizeKey
   } = props;
@@ -40,30 +41,67 @@ const ComparisonTable = (props: Props): React.ReactElement => {
   const matrix = comparator.toJSON();
 
   const handleMouseOut = React.useCallback(() => {
-    onHoverArtifact(null);
-  }, [onHoverArtifact]);
+    onHoverArtifacts([]);
+  }, [onHoverArtifacts]);
+
+  const handleHoverArtifact = React.useCallback(
+    (artifactName: string): void => {
+      onHoverArtifacts([artifactName]);
+    },
+    [onHoverArtifacts]
+  );
+
+  const handleDisableArtifact = React.useCallback(
+    (artifactName: string): void => {
+      onDisableArtifacts([artifactName]);
+    },
+    [onDisableArtifacts]
+  );
+
+  const handleEnableArtifact = React.useCallback(
+    (artifactName: string): void => {
+      onEnableArtifacts([artifactName]);
+    },
+    [onEnableArtifacts]
+  );
 
   return (
     <Table onMouseLeave={handleMouseOut} style={styles.table}>
       <Thead>
         <HeaderRow onFocusRevision={onFocusRevision} onRemoveRevision={onRemoveRevision} row={matrix.header} />
-        <BodyRow
-          colorScale={colorScale}
+        <GroupRow
           isActive={Object.values(activeArtifacts).every(Boolean)}
-          isHovered={false}
           key={'All'}
-          onDisableArtifact={onDisableArtifact}
-          onEnableArtifact={onEnableArtifact}
-          onHoverArtifact={onHoverArtifact}
+          onDisable={onDisableArtifacts}
+          onEnable={onEnableArtifacts}
+          onHover={onHoverArtifacts}
           row={matrix.total}
-          rowIndex={-1}
           sizeKey={sizeKey}
         />
       </Thead>
       <Tbody>
-        {matrix.body.map((row, i) => {
-          // @ts-ignore
-          const artifactName = 'text' in row[0] && row[0].text;
+        {matrix.groups.map(row => {
+          const { artifactNames, text: groupName } = row[0];
+          const isActive = Object.keys(activeArtifacts)
+            .filter(artifactName => artifactNames.includes(artifactName))
+            .every(artifactName => activeArtifacts[artifactName]);
+          if (!isActive && !disabledArtifactsVisible) {
+            return null;
+          }
+          return (
+            <GroupRow
+              isActive={isActive}
+              key={groupName}
+              onDisable={onDisableArtifacts}
+              onEnable={onEnableArtifacts}
+              onHover={onHoverArtifacts}
+              row={row}
+              sizeKey={sizeKey}
+            />
+          );
+        })}
+        {matrix.artifacts.map((row, i) => {
+          const artifactName = row[0].text;
           const isActive = activeArtifacts[artifactName];
           if (!isActive && !disabledArtifactsVisible) {
             return null;
@@ -72,11 +110,11 @@ const ComparisonTable = (props: Props): React.ReactElement => {
             <BodyRow
               colorScale={colorScale}
               isActive={isActive}
-              isHovered={artifactName === hoveredArtifact}
+              isHovered={hoveredArtifacts.includes(artifactName)}
               key={artifactName}
-              onDisableArtifact={onDisableArtifact}
-              onEnableArtifact={onEnableArtifact}
-              onHoverArtifact={onHoverArtifact}
+              onDisableArtifact={handleDisableArtifact}
+              onEnableArtifact={handleEnableArtifact}
+              onHoverArtifact={handleHoverArtifact}
               row={row}
               rowIndex={i}
               sizeKey={sizeKey}
diff --git a/src/app/src/components/ComparisonTable/GroupCell.tsx b/src/app/src/components/ComparisonTable/GroupCell.tsx
new file mode 100644
index 0000000..10f3820
--- /dev/null
+++ b/src/app/src/components/ComparisonTable/GroupCell.tsx
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Theme from '../../theme';
+import FolderIcon from '../../icons/Folder';
+import { GroupCell as GCell } from '@build-tracker/comparator';
+import React from 'react';
+import { Th } from '../Table';
+import Tooltip from '../Tooltip';
+import { StyleProp, StyleSheet, Switch, Text, View, ViewStyle } from 'react-native';
+
+interface Props {
+  cell: GCell;
+  disabled?: boolean;
+  isActive: boolean;
+  onDisable: (artifactNames: Array<string>) => void;
+  onEnable: (artifactNames: Array<string>) => void;
+  style?: StyleProp<ViewStyle>;
+}
+
+export const GroupCell = (props: Props): React.ReactElement => {
+  const {
+    cell: { artifactNames, text },
+    disabled,
+    isActive,
+    onDisable,
+    onEnable,
+    style
+  } = props;
+
+  const nameRef = React.useRef<View>(null);
+  const [showTooltip, setShowTooltip] = React.useState(false);
+
+  const handleValueChange = React.useCallback(
+    (toggled: boolean): void => {
+      toggled ? onEnable(artifactNames) : onDisable(artifactNames);
+    },
+    [artifactNames, onDisable, onEnable]
+  );
+
+  const handleToggleTooltip = React.useCallback(() => {
+    setShowTooltip(showTooltip => !showTooltip);
+  }, []);
+
+  return (
+    <Th style={style}>
+      <View style={styles.artifact}>
+        <View ref={nameRef} style={styles.name}>
+          <Text>
+            {
+              // @ts-ignore
+              <FolderIcon
+                onMouseEnter={handleToggleTooltip}
+                onMouseLeave={handleToggleTooltip}
+                style={styles.folder}
+                testID="groupicon"
+              />
+            }{' '}
+            {text}
+          </Text>
+        </View>
+        {showTooltip ? <Tooltip relativeTo={nameRef} text={`${artifactNames.join(', ')}`} /> : null}
+        <View style={styles.switch}>
+          {
+            // @ts-ignore
+            <Switch
+              activeThumbColor={Theme.Color.Primary30}
+              activeTrackColor={Theme.Color.Primary00}
+              disabled={disabled}
+              onValueChange={handleValueChange}
+              value={isActive}
+            />
+          }
+        </View>
+      </View>
+    </Th>
+  );
+};
+
+const styles = StyleSheet.create({
+  artifact: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    paddingVertical: Theme.Spacing.Xxsmall
+  },
+  name: {
+    flexShrink: 1,
+    justifyContent: 'center',
+    paddingEnd: Theme.Spacing.Xsmall
+  },
+  folder: {
+    color: Theme.Color.Gray40
+  },
+  switch: {
+    paddingStart: Theme.Spacing.Xsmall
+  }
+});
+
+export default React.memo(GroupCell);
diff --git a/src/app/src/components/ComparisonTable/GroupRow.tsx b/src/app/src/components/ComparisonTable/GroupRow.tsx
new file mode 100644
index 0000000..99fcc3b
--- /dev/null
+++ b/src/app/src/components/ComparisonTable/GroupRow.tsx
@@ -0,0 +1,81 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Theme from '../../theme';
+import GroupCell from './GroupCell';
+import React from 'react';
+import { StyleSheet } from 'react-native';
+import TotalCell from './TotalCell';
+import TotalDeltaCell from './TotalDeltaCell';
+import { Tr } from './../Table';
+import {
+  CellType,
+  DeltaCell as DCell,
+  GroupCell as GCell,
+  GroupRow as GRow,
+  TotalCell as TCell,
+  TotalDeltaCell as TDCell
+} from '@build-tracker/comparator';
+
+interface Props {
+  isActive: boolean;
+  onDisable: (artifactNames: Array<string>) => void;
+  onEnable: (artifactNames: Array<string>) => void;
+  onHover: (artifactNames: Array<string>) => void;
+  row: GRow;
+  sizeKey: string;
+}
+
+export const GroupRow = (props: Props): React.ReactElement => {
+  const { isActive, onDisable, onEnable, onHover, row, sizeKey } = props;
+
+  const mapGroupCell = (cell: GCell | TCell | TDCell | DCell, i: number): React.ReactElement | void => {
+    switch (cell.type) {
+      case CellType.GROUP: {
+        return (
+          <GroupCell
+            cell={cell}
+            key={i}
+            isActive={isActive}
+            onDisable={onDisable}
+            onEnable={onEnable}
+            style={styles.cell}
+          />
+        );
+      }
+      case CellType.TOTAL:
+        return <TotalCell cell={cell} key={i} sizeKey={sizeKey} style={styles.cell} />;
+      case CellType.TOTAL_DELTA:
+        return <TotalDeltaCell cell={cell} key={i} sizeKey={sizeKey} style={styles.cell} />;
+    }
+  };
+
+  const { artifactNames } = row[0];
+
+  const handleMouseEnter = React.useCallback(() => {
+    onHover(isActive ? artifactNames : []);
+  }, [artifactNames, isActive, onHover]);
+
+  // @ts-ignore
+  const rows = row.map(mapGroupCell);
+
+  return (
+    <Tr onMouseEnter={handleMouseEnter} style={[styles.row]}>
+      {rows}
+    </Tr>
+  );
+};
+
+const styles = StyleSheet.create({
+  row: {
+    backgroundColor: Theme.Color.Gray05,
+    // @ts-ignore
+    transitionProperty: 'background-color',
+    transitionDuration: '0.1s'
+  },
+  cell: {
+    borderColor: Theme.Color.Gray20
+  }
+});
+
+export default React.memo(GroupRow);
diff --git a/src/app/src/components/ComparisonTable/HeaderRow.tsx b/src/app/src/components/ComparisonTable/HeaderRow.tsx
index ca3fe93..ef36359 100644
--- a/src/app/src/components/ComparisonTable/HeaderRow.tsx
+++ b/src/app/src/components/ComparisonTable/HeaderRow.tsx
@@ -7,12 +7,12 @@ import RevisionDeltaCell from './RevisionDeltaCell';
 import { StyleSheet } from 'react-native';
 import TextCell from './TextCell';
 import { Tr } from './../Table';
-import { CellType, HeaderCell } from '@build-tracker/comparator';
+import { CellType, HeaderRow as HRow } from '@build-tracker/comparator';
 
 interface Props {
   onFocusRevision: (artifactName: string) => void;
   onRemoveRevision: (artifactName: string) => void;
-  row: Array<HeaderCell>;
+  row: HRow;
 }
 
 export const HeaderRow = (props: Props): React.ReactElement => {
diff --git a/src/app/src/components/Graph/Area.tsx b/src/app/src/components/Graph/Area.tsx
index c271a2e..4394350 100644
--- a/src/app/src/components/Graph/Area.tsx
+++ b/src/app/src/components/Graph/Area.tsx
@@ -15,26 +15,26 @@ interface Props {
   colorScale: ScaleSequential<string>;
   comparator: Comparator;
   data: Array<Series<Build, string>>;
-  hoveredArtifact: string;
+  hoveredArtifacts: Array<string>;
   xScale: ScalePoint<string>;
   yScale: ScaleLinear<number, number>;
 }
 
 const Area = (props: Props): React.ReactElement => {
-  const { activeArtifactNames, comparator, data, hoveredArtifact, xScale, yScale } = props;
+  const { activeArtifactNames, comparator, data, hoveredArtifacts, xScale, yScale } = props;
   const colorScale = props.colorScale.domain([0, comparator.artifactNames.length]);
   const gRef = React.useRef(null);
 
   const graphColorScale = React.useMemo(() => {
     return d => {
       const color = hsl(colorScale(comparator.artifactNames.indexOf(d.key)));
-      if (hoveredArtifact && d.key !== hoveredArtifact) {
+      if (hoveredArtifacts.length && !hoveredArtifacts.includes(d.key)) {
         color.l = 0.75;
         color.s = 0.4;
       }
       return color.toString();
     };
-  }, [colorScale, comparator.artifactNames, hoveredArtifact]);
+  }, [colorScale, comparator.artifactNames, hoveredArtifacts]);
 
   React.useEffect(() => {
     const contents = select(gRef.current);
diff --git a/src/app/src/components/Graph/HoverOverlay.tsx b/src/app/src/components/Graph/HoverOverlay.tsx
index c9c6703..8dcb369 100644
--- a/src/app/src/components/Graph/HoverOverlay.tsx
+++ b/src/app/src/components/Graph/HoverOverlay.tsx
@@ -13,7 +13,7 @@ import { ScaleLinear, ScalePoint } from 'd3-scale';
 interface Props {
   data: Array<Series<Build, string>>;
   height: number;
-  onHoverArtifact: (artifactName: string) => void;
+  onHoverArtifacts: (artifactNames: Array<string>) => void;
   onSelectRevision: (revision: string) => void;
   selectedRevisions: Array<string>;
   width: number;
@@ -32,7 +32,7 @@ const handleMoveLine = memoize(
 );
 
 const HoverOverlay = (props: Props): React.ReactElement => {
-  const { data, height, onHoverArtifact, onSelectRevision, selectedRevisions, width, xScale, yScale } = props;
+  const { data, height, onHoverArtifacts, onSelectRevision, selectedRevisions, width, xScale, yScale } = props;
   const lineRef = React.useRef(null);
   const domain = xScale.domain();
 
@@ -82,15 +82,15 @@ const HoverOverlay = (props: Props): React.ReactElement => {
       const hoveredArtifact = data.find(data => {
         return data[revision.index][0] <= yValue && data[revision.index][1] >= yValue;
       });
-      onHoverArtifact(hoveredArtifact ? hoveredArtifact.key : null);
+      onHoverArtifacts(hoveredArtifact ? [hoveredArtifact.key] : []);
     },
-    [buildRevisionFromX, data, onHoverArtifact, xScale, yScale]
+    [buildRevisionFromX, data, onHoverArtifacts, xScale, yScale]
   );
 
   const handleMouseOut = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 0);
-    onHoverArtifact(null);
-  }, [onHoverArtifact]);
+    onHoverArtifacts([]);
+  }, [onHoverArtifacts]);
 
   const handleMouseOver = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 1);
diff --git a/src/app/src/components/Graph/index.tsx b/src/app/src/components/Graph/index.tsx
index 2df7ed8..9c74db8 100644
--- a/src/app/src/components/Graph/index.tsx
+++ b/src/app/src/components/Graph/index.tsx
@@ -18,8 +18,8 @@ interface Props {
   activeArtifacts: { [key: string]: boolean };
   colorScale: ScaleSequential<string>;
   comparator: Comparator;
-  hoveredArtifact: string;
-  onHoverArtifact: (artifactName: string) => void;
+  hoveredArtifacts: Array<string>;
+  onHoverArtifacts: (artifactNames: Array<string>) => void;
   onSelectRevision: (revision: string) => void;
   selectedRevisions: Array<string>;
   sizeKey: string;
@@ -36,8 +36,8 @@ const Graph = (props: Props): React.ReactElement => {
     activeArtifacts,
     colorScale,
     comparator,
-    hoveredArtifact,
-    onHoverArtifact,
+    hoveredArtifacts,
+    onHoverArtifacts,
     onSelectRevision,
     selectedRevisions,
     sizeKey
@@ -99,14 +99,14 @@ const Graph = (props: Props): React.ReactElement => {
                 colorScale={colorScale}
                 comparator={comparator}
                 data={data}
-                hoveredArtifact={hoveredArtifact}
+                hoveredArtifacts={hoveredArtifacts}
                 xScale={xScale}
                 yScale={yScale}
               />
               <HoverOverlay
                 data={data}
                 height={height - Offset.TOP - Offset.BOTTOM}
-                onHoverArtifact={onHoverArtifact}
+                onHoverArtifacts={onHoverArtifacts}
                 onSelectRevision={onSelectRevision}
                 selectedRevisions={selectedRevisions}
                 width={width - Offset.LEFT - Offset.RIGHT}
diff --git a/src/app/src/components/Tooltip.tsx b/src/app/src/components/Tooltip.tsx
index 8dad9f1..9778ef1 100644
--- a/src/app/src/components/Tooltip.tsx
+++ b/src/app/src/components/Tooltip.tsx
@@ -67,6 +67,7 @@ const Tooltip = (props: Props): React.ReactElement => {
 
 const styles = StyleSheet.create({
   root: {
+    maxWidth: 400,
     // @ts-ignore
     position: 'absolute',
     backgroundColor: Theme.Color.Gray50,
diff --git a/src/app/src/icons/Folder.tsx b/src/app/src/icons/Folder.tsx
new file mode 100644
index 0000000..b32ebcd
--- /dev/null
+++ b/src/app/src/icons/Folder.tsx
@@ -0,0 +1,32 @@
+/**
+ * Material design redistributed from https://github.com/google/material-design-icons
+ *
+ * SVG contents redistributed under Apache License 2.0:
+ * https://github.com/google/material-design-icons/blob/master/LICENSE
+ * Copyright 2015 Google, Inc. All Rights Reserved.
+ */
+import React from 'react';
+import styles from './styles';
+import { createElement, StyleProp, TextStyle, ViewStyle } from 'react-native';
+
+interface Props {
+  style?: StyleProp<ViewStyle & TextStyle>;
+}
+
+const Folder = (props: Props): React.ReactElement<Props> =>
+  createElement(
+    'svg',
+    {
+      ...props,
+      style: [styles.root, props.style],
+      viewBox: '0 0 24 24'
+    },
+    <g>
+      <path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
+      <path d="M0 0h24v24H0z" fill="none" />
+    </g>
+  );
+
+Folder.metadata = { height: 24, width: 24 };
+
+export default Folder;
diff --git a/src/app/src/screens/Main.tsx b/src/app/src/screens/Main.tsx
index c3e8401..112cca6 100644
--- a/src/app/src/screens/Main.tsx
+++ b/src/app/src/screens/Main.tsx
@@ -18,7 +18,7 @@ import MenuItem from '../components/MenuItem';
 import React from 'react';
 import { ScaleSequential } from 'd3-scale';
 import { Clipboard, StyleSheet, View } from 'react-native';
-import Comparator, { ArtifactCell, BodyCell } from '@build-tracker/comparator';
+import Comparator, { ArtifactRow } from '@build-tracker/comparator';
 
 const Comparison = React.lazy(() => import(/* webpackChunkName: "Comparison" */ '../views/Comparison'));
 
@@ -30,6 +30,8 @@ const builds = [
   new Build(buildDataE.meta, buildDataE.artifacts)
 ];
 
+const groups = [{ name: 'Home', artifactNames: ['main', 'vendor', 'shared', 'runtime', 'bundle.HomeTimeline'] }];
+
 const Main = (): React.ReactElement => {
   const drawerRef: React.RefObject<Drawer> = React.useRef(null);
   const [compareRevisions, setCompareRevisions] = React.useState<Array<string>>([]);
@@ -37,7 +39,8 @@ const Main = (): React.ReactElement => {
   const activeComparator = React.useMemo(
     (): Comparator =>
       new Comparator({
-        builds: builds.filter(build => compareRevisions.indexOf(build.getMetaValue('revision')) !== -1)
+        builds: builds.filter(build => compareRevisions.indexOf(build.getMetaValue('revision')) !== -1),
+        groups
       }),
     [compareRevisions]
   );
@@ -45,7 +48,7 @@ const Main = (): React.ReactElement => {
   const [colorScale, setColorScale] = React.useState<ScaleSequential<string>>(() => ColorScale.Rainbow);
   const [sizeKey, setSizeKey] = React.useState<string>(comparator.sizeKeys[0]);
   const [focusedRevision, setFocusedRevision] = React.useState<string>(null);
-  const [hoveredArtifact, setHoveredArtifact] = React.useState<string>(null);
+  const [hoveredArtifacts, setHoveredArtifacts] = React.useState<Array<string>>([]);
   const [disabledArtifactsVisible, setDisabledArtifactsVisible] = React.useState<boolean>(true);
   const [activeArtifacts, setActiveArtifacts] = React.useState<{ [key: string]: boolean }>(
     comparator.artifactNames.reduce((memo: { [key: string]: boolean }, name: string) => {
@@ -63,30 +66,22 @@ const Main = (): React.ReactElement => {
     setColorScale(() => scale);
   }, []);
 
-  const handleDisableArtifact = React.useCallback((name: string): void => {
+  const handleDisableArtifacts = React.useCallback((artifactNames: Array<string>): void => {
     setActiveArtifacts(activeArtifacts => {
-      if (name === 'All') {
-        Object.keys(activeArtifacts).forEach(name => {
-          activeArtifacts[name] = false;
-        });
-      } else {
-        activeArtifacts[name] = false;
-      }
-      return activeArtifacts;
+      return Object.entries(activeArtifacts).reduce((memo, [artifactName, isActive]) => {
+        memo[artifactName] = artifactNames.includes(artifactName) ? false : isActive;
+        return memo;
+      }, {});
     });
     forceUpdate(Date.now());
   }, []);
 
-  const handleEnableArtifact = React.useCallback((name: string): void => {
+  const handleEnableArtifacts = React.useCallback((artifactNames: Array<string>): void => {
     setActiveArtifacts(activeArtifacts => {
-      if (name === 'All') {
-        Object.keys(activeArtifacts).forEach(name => {
-          activeArtifacts[name] = true;
-        });
-      } else {
-        activeArtifacts[name] = true;
-      }
-      return activeArtifacts;
+      return Object.entries(activeArtifacts).reduce((memo, [artifactName, isActive]) => {
+        memo[artifactName] = artifactNames.includes(artifactName) ? true : isActive;
+        return memo;
+      }, {});
     });
     forceUpdate(Date.now());
   }, []);
@@ -102,6 +97,7 @@ const Main = (): React.ReactElement => {
 
   const handleClearRevisions = React.useCallback((): void => {
     setCompareRevisions([]);
+    setFocusedRevision(null);
   }, []);
 
   const handleUnfocusRevision = React.useCallback((): void => {
@@ -122,28 +118,28 @@ const Main = (): React.ReactElement => {
     setDisabledArtifactsVisible(showDisabled);
   }, []);
 
-  const rowFilter = (row: Array<BodyCell>): boolean => {
-    const artifactCell = row[0] as ArtifactCell;
+  const artifactFilter = (row: ArtifactRow): boolean => {
+    const artifactCell = row[0];
     return activeArtifacts[artifactCell.text];
   };
 
   const handleCopyAsMarkdown = React.useCallback((): void => {
     Clipboard.setString(
       activeComparator.toMarkdown({
-        rowFilter,
+        artifactFilter,
         sizeKey
       })
     );
-  }, [activeComparator, rowFilter, sizeKey]);
+  }, [activeComparator, artifactFilter, sizeKey]);
 
   const handleCopyAsCsv = React.useCallback((): void => {
     Clipboard.setString(
       activeComparator.toCsv({
-        rowFilter,
+        artifactFilter,
         sizeKey
       })
     );
-  }, [activeComparator, rowFilter, sizeKey]);
+  }, [activeComparator, artifactFilter, sizeKey]);
 
   const overflowItems = React.useMemo(
     () =>
@@ -185,8 +181,8 @@ const Main = (): React.ReactElement => {
             activeArtifacts={activeArtifacts}
             colorScale={colorScale}
             comparator={comparator}
-            hoveredArtifact={hoveredArtifact}
-            onHoverArtifact={setHoveredArtifact}
+            hoveredArtifacts={hoveredArtifacts}
+            onHoverArtifacts={setHoveredArtifacts}
             onSelectRevision={handleSelectRevision}
             selectedRevisions={compareRevisions}
             sizeKey={sizeKey}
@@ -200,12 +196,12 @@ const Main = (): React.ReactElement => {
               comparator={activeComparator}
               disabledArtifactsVisible={disabledArtifactsVisible}
               focusedRevision={focusedRevision}
-              hoveredArtifact={hoveredArtifact}
-              onDisableArtifact={handleDisableArtifact}
-              onEnableArtifact={handleEnableArtifact}
+              hoveredArtifacts={hoveredArtifacts}
+              onDisableArtifacts={handleDisableArtifacts}
+              onEnableArtifacts={handleEnableArtifacts}
               onFocusRevision={setFocusedRevision}
               onUnfocusRevision={handleUnfocusRevision}
-              onHoverArtifact={setHoveredArtifact}
+              onHoverArtifacts={setHoveredArtifacts}
               onRemoveRevision={handleRemoveRevision}
               sizeKey={sizeKey}
               style={[styles.column, styles.table]}
diff --git a/src/app/src/views/Comparison.tsx b/src/app/src/views/Comparison.tsx
index 2f984b9..b93a9ba 100644
--- a/src/app/src/views/Comparison.tsx
+++ b/src/app/src/views/Comparison.tsx
@@ -20,11 +20,11 @@ const Comparison = (props: Props): React.ReactElement => {
     comparator,
     disabledArtifactsVisible,
     focusedRevision,
-    hoveredArtifact,
-    onDisableArtifact,
-    onEnableArtifact,
+    hoveredArtifacts,
+    onDisableArtifacts,
+    onEnableArtifacts,
     onFocusRevision,
-    onHoverArtifact,
+    onHoverArtifacts,
     onRemoveRevision,
     onUnfocusRevision,
     sizeKey,
@@ -39,11 +39,11 @@ const Comparison = (props: Props): React.ReactElement => {
             colorScale={colorScale}
             comparator={comparator}
             disabledArtifactsVisible={disabledArtifactsVisible}
-            hoveredArtifact={hoveredArtifact}
-            onDisableArtifact={onDisableArtifact}
-            onEnableArtifact={onEnableArtifact}
+            hoveredArtifacts={hoveredArtifacts}
+            onDisableArtifacts={onDisableArtifacts}
+            onEnableArtifacts={onEnableArtifacts}
             onFocusRevision={onFocusRevision}
-            onHoverArtifact={onHoverArtifact}
+            onHoverArtifacts={onHoverArtifacts}
             onRemoveRevision={onRemoveRevision}
             sizeKey={sizeKey}
           />
diff --git a/src/build/src/index.ts b/src/build/src/index.ts
index de732f8..d8106da 100644
--- a/src/build/src/index.ts
+++ b/src/build/src/index.ts
@@ -113,12 +113,14 @@ export default class Build<M extends BuildMeta = BuildMeta, A extends ArtifactSi
   public getSum(artifactNames: Array<string>): ArtifactSizes {
     return artifactNames.reduce((sum: ArtifactSizes, artifactName: string) => {
       const artifact = this._artifacts.get(artifactName);
-      Object.entries(artifact.sizes).forEach(([key, value]) => {
-        if (!sum[key]) {
-          sum[key] = 0;
-        }
-        sum[key] += value;
-      });
+      if (artifact) {
+        Object.entries(artifact.sizes).forEach(([key, value]) => {
+          if (!sum[key]) {
+            sum[key] = 0;
+          }
+          sum[key] += value;
+        });
+      }
       return sum;
     }, {});
   }
diff --git a/src/comparator/src/BuildDelta.ts b/src/comparator/src/BuildDelta.ts
index 9162f9a..237d039 100644
--- a/src/comparator/src/BuildDelta.ts
+++ b/src/comparator/src/BuildDelta.ts
@@ -1,15 +1,26 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import { ArtifactFilters } from '@build-tracker/types';
+import { ArtifactBudgets, ArtifactFilters, Budget, Group } from '@build-tracker/types';
 import Build, { ArtifactSizes, BuildMeta } from '@build-tracker/build';
 import { delta, percentDelta } from './artifact-math';
 
+const emptyObject = Object.freeze({});
+
+interface BudgetResult {
+  passing: boolean;
+  expected: number;
+  actual: number;
+  type: Budget['type'];
+  level: Budget['level'];
+}
+
 interface ArtifactDelta<AS extends ArtifactSizes = ArtifactSizes> {
   hashChanged: boolean;
   name: string;
   sizes: AS;
   percents: AS;
+  budget: { [sizeKey: string]: BudgetResult };
 }
 
 interface BuildSizeDelta<M extends BuildMeta = BuildMeta, AS extends ArtifactSizes = ArtifactSizes> {
@@ -18,19 +29,31 @@ interface BuildSizeDelta<M extends BuildMeta = BuildMeta, AS extends ArtifactSiz
   percents: AS;
 }
 
+export interface DeltaOptions {
+  artifactBudgets?: ArtifactBudgets;
+  artifactFilters?: ArtifactFilters;
+  groups?: Array<Group>;
+}
+
 export default class BuildDelta<M extends BuildMeta = BuildMeta, A extends ArtifactSizes = ArtifactSizes> {
   private _baseBuild: Build<M, A>;
   private _prevBuild: Build<M, A>;
+
+  private _artifactBudgets: ArtifactBudgets;
   private _artifactDeltas: Map<string, ArtifactDelta<A>>;
   private _artifactFilters: ArtifactFilters;
   private _artifactNames: Set<string>;
+  private _groups: Array<Group>;
+  private _groupDeltas: Map<string, ArtifactDelta<A>>;
   private _sizeKeys: Set<string>;
   private _totalDelta: BuildSizeDelta<M, A>;
 
-  public constructor(baseBuild: Build<M, A>, prevBuild: Build<M, A>, artifactFilters?: ArtifactFilters) {
+  public constructor(baseBuild: Build<M, A>, prevBuild: Build<M, A>, options: DeltaOptions = {}) {
     this._baseBuild = baseBuild;
     this._prevBuild = prevBuild;
-    this._artifactFilters = artifactFilters || [];
+    this._artifactBudgets = options.artifactBudgets || emptyObject;
+    this._artifactFilters = options.artifactFilters || [];
+    this._groups = options.groups || [];
   }
 
   public get baseBuild(): Build<M, A> {
@@ -101,8 +124,30 @@ export default class BuildDelta<M extends BuildMeta = BuildMeta, A extends Artif
           return memo;
         }, {});
 
+        let budgets = [];
+        (this._artifactBudgets[artifactName] || []).forEach(budget => {
+          const { level, maximum, sizeKey, type } = budget;
+          const actual =
+            type === 'delta'
+              ? sizeDeltas[sizeKey]
+              : type === 'percentDelta'
+              ? percentDeltas[sizeKey]
+              : baseArtifact
+              ? baseArtifact.sizes[sizeKey]
+              : 0;
+          const expected = maximum;
+          budgets.push({
+            passing: actual < expected,
+            type,
+            level,
+            actual,
+            expected
+          });
+        });
+
         this._artifactDeltas.set(artifactName, {
           name: artifactName,
+          budgets,
           hashChanged: !baseArtifact || !prevArtifact || baseArtifact.hash !== prevArtifact.hash,
           // @ts-ignore constructed above
           sizes: sizeDeltas,
@@ -115,6 +160,69 @@ export default class BuildDelta<M extends BuildMeta = BuildMeta, A extends Artif
     return Array.from(this._artifactDeltas.values());
   }
 
+  public getGroupDelta(groupName: string): ArtifactDelta<A> {
+    if (!this._groupDeltas) {
+      this.groupDeltas;
+    }
+    return this._groupDeltas.get(groupName);
+  }
+
+  public get groupDeltas(): Map<string, ArtifactDelta<A>> {
+    if (!this._groupDeltas) {
+      this._groupDeltas = new Map();
+      this._groups.forEach(group => {
+        const baseSum = this._baseBuild.getSum(group.artifactNames);
+        const prevSum = this._prevBuild.getSum(group.artifactNames);
+
+        const hashChanged = group.artifactNames.reduce((changed, artifactName) => {
+          const baseArtifact = this._baseBuild.getArtifact(artifactName);
+          const prevArtifact = this._prevBuild.getArtifact(artifactName);
+          return changed ? true : !baseArtifact || !prevArtifact || baseArtifact.hash !== prevArtifact.hash;
+        }, false);
+
+        const sizes = {};
+        const percents = {};
+        Object.keys(baseSum).forEach(sizeKey => {
+          sizes[sizeKey] = delta(sizeKey, baseSum, prevSum);
+          percents[sizeKey] = percentDelta(sizeKey, baseSum, prevSum);
+        });
+
+        const budgets = [];
+        (group.budgets || []).forEach(budget => {
+          const { level, maximum, sizeKey, type } = budget;
+          const actual =
+            type === 'delta'
+              ? sizes[sizeKey]
+              : type === 'percentDelta'
+              ? percents[sizeKey]
+              : baseSum
+              ? baseSum[sizeKey]
+              : 0;
+          const expected = maximum;
+          budgets.push({
+            passing: actual < expected,
+            type,
+            level,
+            actual,
+            expected
+          });
+        });
+
+        this._groupDeltas.set(group.name, {
+          name: group.name,
+          hashChanged,
+          // @ts-ignore
+          sizes,
+          // @ts-ignore
+          percents,
+          budgets
+        });
+      });
+    }
+
+    return this._groupDeltas;
+  }
+
   public get totalDelta(): BuildSizeDelta<M, A> {
     if (!this._totalDelta) {
       const baseTotals = this._baseBuild.getTotals(this._artifactFilters);
diff --git a/src/comparator/src/index.ts b/src/comparator/src/index.ts
index 1d7126c..e610a8c 100644
--- a/src/comparator/src/index.ts
+++ b/src/comparator/src/index.ts
@@ -1,10 +1,10 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import { ArtifactFilters } from '@build-tracker/types';
 import Build from '@build-tracker/build';
 import BuildDelta from './BuildDelta';
 import markdownTable from 'markdown-table';
+import { ArtifactBudgets, ArtifactFilters, Group } from '@build-tracker/types';
 import { formatBytes, formatSha } from '@build-tracker/formatting';
 
 export interface ArtifactSizes {
@@ -18,7 +18,8 @@ export enum CellType {
   TOTAL_DELTA = 'totalDelta',
   REVISION = 'revision',
   REVISION_DELTA = 'revisionDelta',
-  ARTIFACT = 'artifact'
+  ARTIFACT = 'artifact',
+  GROUP = 'group'
 }
 
 export interface TextCell {
@@ -30,12 +31,13 @@ export interface DeltaCell {
   type: CellType.DELTA;
   sizes: ArtifactSizes;
   percents: ArtifactSizes;
-  name?: string;
+  name: string;
   hashChanged: boolean;
 }
 
 export interface TotalCell {
   type: CellType.TOTAL;
+  name: string;
   sizes: ArtifactSizes;
 }
 
@@ -62,35 +64,38 @@ export interface ArtifactCell {
   text: string;
 }
 
-export type BodyCell = ArtifactCell | DeltaCell | TotalCell;
-export type HeaderCell = TextCell | RevisionCell | RevisionDeltaCell;
+export interface GroupCell {
+  type: CellType.GROUP;
+  text: string;
+  artifactNames: Array<string>;
+}
+export type HeaderRow = [TextCell, ...(Array<RevisionCell | RevisionDeltaCell>)];
+export type ArtifactRow = [ArtifactCell, ...(Array<TotalCell | DeltaCell>)];
+export type GroupRow = [GroupCell, ...(Array<TotalCell | TotalDeltaCell>)];
 
 export interface ComparisonMatrix {
-  header: Array<HeaderCell>;
-  total: Array<BodyCell | TotalDeltaCell>;
-  body: Array<Array<BodyCell>>;
+  header: HeaderRow;
+  total: GroupRow;
+  groups: Array<GroupRow>;
+  artifacts: Array<ArtifactRow>;
 }
 
 type RevisionStringFormatter = (cell: RevisionCell) => string;
 type RevisionDeltaStringFormatter = (cell: RevisionDeltaCell) => string;
 type TotalStringFormatter = (cell: TotalCell, sizeKey: string) => string;
 type DeltaStringFormatter = (cell: DeltaCell | TotalDeltaCell, sizeKey: string) => string;
-type RowFilter = (row: Array<BodyCell>) => boolean;
+type ArtifactFilter = (row: ArtifactRow) => boolean;
 
 interface FormattingOptions {
   formatRevision?: RevisionStringFormatter;
   formatRevisionDelta?: RevisionDeltaStringFormatter;
   formatTotal?: TotalStringFormatter;
   formatDelta?: DeltaStringFormatter;
-  rowFilter?: RowFilter;
+  artifactFilter?: ArtifactFilter;
   sizeKey?: string;
 }
 
-const emptyArtifact = Object.freeze({
-  name: '',
-  hash: '',
-  sizes: {}
-});
+const emptyObject = Object.freeze({});
 
 const getTotalArtifactSizes = (build: Build, artifactFilters: ArtifactFilters): TotalCell =>
   // @ts-ignore reducing doesn't seem to make typescript happy
@@ -99,6 +104,7 @@ const getTotalArtifactSizes = (build: Build, artifactFilters: ArtifactFilters):
     .reduce(
       (totalMemo, artifactName): TotalCell => {
         return {
+          name: artifactName,
           type: CellType.TOTAL,
           sizes: Object.entries(build.getArtifact(artifactName).sizes).reduce((memo, [key, value]) => {
             if (!memo[key]) {
@@ -121,30 +127,36 @@ const defaultFormatRevisionDelta = (cell: RevisionDeltaCell): string => `Δ${cel
 const defaultFormatTotal = (cell: TotalCell, sizeKey: string): string => formatBytes(cell.sizes[sizeKey] || 0);
 const defaultFormatDelta = (cell: DeltaCell | TotalDeltaCell, sizeKey: string): string =>
   `${formatBytes(cell.sizes[sizeKey] || 0)} (${((cell.percents[sizeKey] || 0) * 100).toFixed(1)}%)`;
-const defaultRowFilter = (): boolean => true;
-
-const emptyArray = [];
+const defaultArtifactFilter = (): boolean => true;
 
 interface ComparatorOptions {
+  artifactBudgets?: ArtifactBudgets;
   artifactFilters?: ArtifactFilters;
   builds: Array<Build>;
+  groups?: Array<Group>;
 }
 
 export default class BuildComparator {
   public builds: Array<Build>;
 
+  private _artifactBudgets: ArtifactBudgets;
   private _artifactFilters: ArtifactFilters;
+  private _groups: Array<Group>;
+
   private _artifactNames: Array<string>;
   private _sizeKeys: Array<string>;
   private _buildDeltas: Array<Array<BuildDelta>>;
 
-  private _matrixHeader: Array<HeaderCell>;
-  private _matrixTotal: Array<BodyCell>;
-  private _matrixBody: Array<Array<BodyCell>>;
+  private _matrixHeader: ComparisonMatrix['header'];
+  private _matrixTotal: ComparisonMatrix['total'];
+  private _matrixGroups: ComparisonMatrix['groups'];
+  private _matrixArtifacts: ComparisonMatrix['artifacts'];
 
-  public constructor({ artifactFilters, builds }: ComparatorOptions) {
+  public constructor({ artifactBudgets, artifactFilters, builds, groups }: ComparatorOptions) {
     this.builds = builds;
-    this._artifactFilters = artifactFilters || emptyArray;
+    this._artifactFilters = artifactFilters || [];
+    this._artifactBudgets = artifactBudgets || emptyObject;
+    this._groups = groups || [];
   }
 
   public get artifactNames(): Array<string> {
@@ -187,7 +199,11 @@ export default class BuildComparator {
     if (!this._buildDeltas) {
       this._buildDeltas = this.builds.map((baseBuild, i) => {
         return this.builds.slice(0, i).map(prevBuild => {
-          return new BuildDelta(baseBuild, prevBuild, this._artifactFilters);
+          return new BuildDelta(baseBuild, prevBuild, {
+            artifactBudgets: this._artifactBudgets,
+            artifactFilters: this._artifactFilters,
+            groups: this._groups
+          });
         });
       });
     }
@@ -195,7 +211,7 @@ export default class BuildComparator {
     return this._buildDeltas;
   }
 
-  public get matrixHeader(): Array<HeaderCell> {
+  public get matrixHeader(): ComparisonMatrix['header'] {
     if (!this._matrixHeader) {
       this._matrixHeader = [
         { type: CellType.TEXT, text: '' },
@@ -218,10 +234,10 @@ export default class BuildComparator {
     return this._matrixHeader;
   }
 
-  public get matrixTotal(): Array<BodyCell | TotalDeltaCell> {
+  public get matrixTotal(): ComparisonMatrix['total'] {
     if (!this._matrixTotal) {
       this._matrixTotal = [
-        { type: CellType.ARTIFACT, text: 'All' },
+        { type: CellType.GROUP, text: 'All', artifactNames: this.artifactNames },
         ...flatten(
           this.buildDeltas.map((buildDeltas, i) => [
             {
@@ -240,35 +256,66 @@ export default class BuildComparator {
     return this._matrixTotal;
   }
 
-  public get matrixBody(): Array<Array<BodyCell>> {
-    if (!this._matrixBody) {
-      this._matrixBody = this.artifactNames.map(artifactName => {
-        const cells = this.buildDeltas.map(
-          (buildDeltas, i): Array<TextCell | TotalCell | DeltaCell> => {
-            const artifact = this.builds[i].getArtifact(artifactName);
-            return [
-              {
-                ...(artifact ? artifact : emptyArtifact),
-                type: CellType.TOTAL
-              },
-              // @ts-ignore
-              ...buildDeltas.map(buildDelta => ({
-                ...buildDelta.getArtifactDelta(artifactName),
-                type: CellType.DELTA
-              }))
-            ];
-          }
-        );
-        return [{ type: CellType.ARTIFACT, text: artifactName }, ...flatten(cells)];
-      });
+  public get matrixGroups(): ComparisonMatrix['groups'] {
+    if (!this._matrixGroups) {
+      this._matrixGroups = this._groups.map(group => this._getGroupRow(group));
     }
-    return this._matrixBody;
+    return this._matrixGroups;
+  }
+
+  public get matrixArtifacts(): ComparisonMatrix['artifacts'] {
+    if (!this._matrixArtifacts) {
+      this._matrixArtifacts = this.artifactNames.map(artifactName => this._getArtifactRow(artifactName));
+    }
+    return this._matrixArtifacts;
+  }
+
+  private _getArtifactRow(artifactName: string): ArtifactRow {
+    const cells = this.buildDeltas.map(
+      (buildDeltas, i): Array<TextCell | TotalCell | DeltaCell> => {
+        const artifact = this.builds[i].getArtifact(artifactName);
+        return [
+          {
+            sizes: artifact ? artifact.sizes : emptyObject,
+            name: artifactName,
+            type: CellType.TOTAL
+          },
+          // @ts-ignore
+          ...buildDeltas.map(buildDelta => ({
+            ...buildDelta.getArtifactDelta(artifactName),
+            type: CellType.DELTA
+          }))
+        ];
+      }
+    );
+    return [{ type: CellType.ARTIFACT, text: artifactName }, ...flatten(cells)];
+  }
+
+  private _getGroupRow(group: Group): GroupRow {
+    const cells = this.buildDeltas.map(
+      (buildDeltas, i): Array<TextCell | TotalCell | DeltaCell> => {
+        const groupSizes = this.builds[i].getSum(group.artifactNames);
+        return [
+          {
+            sizes: groupSizes,
+            name: group.name,
+            type: CellType.TOTAL
+          },
+          // @ts-ignore
+          ...buildDeltas.map(buildDelta => ({
+            ...buildDelta.getGroupDelta(group.name),
+            type: CellType.TOTAL_DELTA
+          }))
+        ];
+      }
+    );
+    return [{ type: CellType.GROUP, text: group.name, artifactNames: group.artifactNames }, ...flatten(cells)];
   }
 
-  public getSum(artifactNames: Array<string>): Array<BodyCell> {
+  public getSum(artifactNames: Array<string>): GroupRow {
     const filters = [new RegExp(`^(?!(${artifactNames.join('|')})$).*$`)];
     return [
-      { type: CellType.TEXT, text: 'Sum' },
+      { type: CellType.GROUP, text: 'Sum', artifactNames },
       ...flatten(
         this.builds.map((changeBuild, buildIndex) => [
           getTotalArtifactSizes(changeBuild, filters),
@@ -302,9 +349,8 @@ export default class BuildComparator {
     return this.matrixTotal.map(
       (cell): string => {
         switch (cell.type) {
-          case CellType.ARTIFACT:
+          case CellType.GROUP:
             return cell.text;
-          case CellType.DELTA:
           case CellType.TOTAL_DELTA:
             return formatDelta(cell as TotalDeltaCell, sizeKey);
           case CellType.TOTAL:
@@ -318,9 +364,9 @@ export default class BuildComparator {
     formatTotal: TotalStringFormatter = defaultFormatTotal,
     formatDelta: DeltaStringFormatter = defaultFormatDelta,
     sizeKey: string = 'gzip',
-    rowFilter: RowFilter = defaultRowFilter
+    artifactFilter: ArtifactFilter = defaultArtifactFilter
   ): Array<Array<string>> {
-    return this.matrixBody.filter(rowFilter).map(
+    return this.matrixArtifacts.filter(artifactFilter).map(
       (row): Array<string> => {
         return row.map(
           (cell): string => {
@@ -342,7 +388,8 @@ export default class BuildComparator {
     return {
       header: this.matrixHeader,
       total: this.matrixTotal,
-      body: this.matrixBody
+      groups: this.matrixGroups,
+      artifacts: this.matrixArtifacts
     };
   }
 
@@ -351,12 +398,12 @@ export default class BuildComparator {
     formatRevisionDelta,
     formatTotal,
     formatDelta,
-    rowFilter,
+    artifactFilter,
     sizeKey
   }: FormattingOptions = {}): string {
     const header = this.getStringFormattedHeader(formatRevision, formatRevisionDelta);
     const total = this.getStringFormattedTotals(formatTotal, formatDelta, sizeKey);
-    const rows = this.getStringFormattedRows(formatTotal, formatDelta, sizeKey, rowFilter);
+    const rows = this.getStringFormattedRows(formatTotal, formatDelta, sizeKey, artifactFilter);
 
     return markdownTable([header, total, ...rows], { align: rows[0].map((_, i) => (i === 0 ? 'l' : 'r')) });
   }
@@ -366,12 +413,12 @@ export default class BuildComparator {
     formatRevisionDelta,
     formatTotal,
     formatDelta,
-    rowFilter,
+    artifactFilter,
     sizeKey
   }: FormattingOptions = {}): string {
     const header = this.getStringFormattedHeader(formatRevision, formatRevisionDelta);
     const total = this.getStringFormattedTotals(formatTotal, formatDelta, sizeKey);
-    const rows = this.getStringFormattedRows(formatTotal, formatDelta, sizeKey, rowFilter);
+    const rows = this.getStringFormattedRows(formatTotal, formatDelta, sizeKey, artifactFilter);
 
     return [header, total, ...rows].map(row => `${row.join(',')}`).join(`\r\n`);
   }
diff --git a/src/server/src/api/index.ts b/src/server/src/api/index.ts
index 923a30d..ee55e45 100644
--- a/src/server/src/api/index.ts
+++ b/src/server/src/api/index.ts
@@ -1,13 +1,19 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import { AppConfig } from '@build-tracker/types';
 import express from 'express';
 import { insertBuild } from './insert';
 import { Handlers, Queries } from '../types';
 import { queryByRevision, queryByRevisionRange, queryByRevisions, queryByTimeRange } from './read';
 
-const middleware = (router: express.Router, queries: Queries, handlers: Handlers): express.Router => {
-  router.post('/api/builds', insertBuild(queries.build.byRevision, handlers.onBuildInsert));
+const middleware = (
+  router: express.Router,
+  queries: Queries,
+  handlers: Handlers,
+  appConfig: AppConfig
+): express.Router => {
+  router.post('/api/builds', insertBuild(queries.build.byRevision, appConfig, handlers.onBuildInsert));
   router.get('/api/builds/range/:startRevision..:endRevision', queryByRevisionRange(queries.builds));
   router.get('/api/builds/time/:startTimestamp..:endTimestamp', queryByTimeRange(queries.builds));
   router.get('/api/builds/list/*', queryByRevisions(queries.builds));
diff --git a/src/server/src/api/insert.ts b/src/server/src/api/insert.ts
index 6669b56..f672229 100644
--- a/src/server/src/api/insert.ts
+++ b/src/server/src/api/insert.ts
@@ -1,6 +1,7 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import { AppConfig } from '@build-tracker/types';
 import Build from '@build-tracker/build';
 import Comparator from '@build-tracker/comparator';
 import { Queries } from '../types';
@@ -8,16 +9,25 @@ import { Request, RequestHandler, Response } from 'express';
 
 export const insertBuild = (
   getParent: Queries['build']['byRevision'],
+  appConfig: AppConfig,
   onInserted: (comparator: Comparator) => Promise<void> = () => Promise.resolve()
 ): RequestHandler => (req: Request, res: Response): void => {
   const { artifacts, meta } = req.body;
   const build = new Build(meta, artifacts);
   getParent(build.getMetaValue('parentRevision'))
     .then(parentBuild => {
-      return new Comparator({ builds: [build, new Build(parentBuild.meta, parentBuild.artifacts)] });
+      return new Comparator({
+        artifactBudgets: appConfig.artifacts.budgets,
+        artifactFilters: appConfig.artifacts.filters,
+        builds: [build, new Build(parentBuild.meta, parentBuild.artifacts)]
+      });
     })
-    .then(onInserted)
-    .then(() => {
-      res.send({ foo: 'bar' });
+    .then(comparator => {
+      return onInserted(comparator).then(() => comparator);
+    })
+    .then(comparator => {
+      res.send({
+        comparator: comparator.toJSON()
+      });
     });
 };
diff --git a/src/server/src/server.ts b/src/server/src/server.ts
index 193f645..869e660 100644
--- a/src/server/src/server.ts
+++ b/src/server/src/server.ts
@@ -2,16 +2,17 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import api from './api';
+import { AppConfig } from '@build-tracker/types';
 import bodyParser from 'body-parser';
 import express from 'express';
 import expressPino from 'express-pino-logger';
 import pino from 'pino';
 import { Handlers, Queries } from './types';
 
-export interface ServerConfig {
-  queries: Queries;
+export interface ServerConfig extends AppConfig {
   handlers?: Handlers;
   port?: number;
+  queries: Queries;
 }
 
 const app = express();
@@ -23,7 +24,7 @@ export default function runBuildTracker(config: ServerConfig): void {
   app.use(reqLogger);
   app.use(bodyParser.json());
 
-  app.use(api(express.Router(), queries, handlers));
+  app.use(api(express.Router(), queries, handlers, config));
 
   app.listen(port);
   logger.info(`Build Tracker server running on port ${port}`);
diff --git a/src/types/index.ts b/src/types/index.ts
index 7ecd01d..51b2d1e 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,43 +1,53 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-export interface SizeBudget {
-  delta?: number;
-  percent?: number;
-}
+export type ArtifactFilters = Array<RegExp>;
 
 export interface Budget {
-  warn?: {
-    [key: string]: SizeBudget;
-  };
-  error?: {
-    [key: string]: SizeBudget;
-  };
-  good?: {
-    [key: string]: SizeBudget;
-  };
+  /**
+   * Budget callout level
+   * warn - inform that this change could be problematic
+   * error - this change violates the budget and should not be accepted
+   */
+  level: 'warn' | 'error';
+  /**
+   * Type of file size to operate on. Usually 'gzip' or 'stat1'
+   * @type {string}
+   */
+  sizeKey: string;
+  /**
+   * Type of change to moniro
+   * delta - maximum allowed for new value minus previous value
+   * percentDelta - maximum allowed for the percent change from the previous value to the new value
+   * size - absolute maximum value
+   */
+  type: 'delta' | 'percentDelta' | 'size';
+  /**
+   * Maximum value for the type defined above
+   * @type {number}
+   */
+  maximum: number;
 }
 
 export interface Group {
   artifactNames: Array<string>;
-  budget?: Budget;
+  budgets?: Array<Budget>;
   name: string;
 }
 
-export interface ArtifactBudget {
-  budget: Budget;
-  name: string;
+export interface ArtifactBudgets {
+  [artifactName: string]: Array<Budget>;
 }
 
-export type ArtifactFilters = Array<RegExp>;
-
 export interface AppConfig {
   artifacts?: {
-    budgets: Array<ArtifactBudget>;
-    filters: ArtifactFilters;
+    budgets?: ArtifactBudgets;
+    filters?: ArtifactFilters;
+    groups?: Array<Group>;
   };
-  budgets?: Budget;
-  groups?: Array<Group>;
-  root: string;
-  routing?: 'hash' | 'history';
+  /**
+   * Budgets for the sum of all artifacts
+   * @type {Array<Budget>}
+   */
+  budgets?: Array<Budget>;
 }


Test patch:
diff --git a/src/app/src/components/ComparisonTable/__tests__/BodyRow.test.tsx b/src/app/src/components/ComparisonTable/__tests__/BodyRow.test.tsx
index 1de7931..216ffeb 100644
--- a/src/app/src/components/ComparisonTable/__tests__/BodyRow.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/BodyRow.test.tsx
@@ -8,15 +8,14 @@ import { DeltaCell } from '../DeltaCell';
 import React from 'react';
 import { StyleSheet } from 'react-native';
 import { TotalCell } from '../TotalCell';
-import { TotalDeltaCell } from '../TotalDeltaCell';
 import { Tr } from '../../Table';
-import { BodyCell, CellType, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
+import { ArtifactRow, CellType } from '@build-tracker/comparator';
 import { fireEvent, render } from 'react-native-testing-library';
 
 describe('BodyRow', () => {
   describe('render', () => {
     test('artifact cell', () => {
-      const row: Array<BodyCell> = [{ type: CellType.ARTIFACT, text: 'tacos' }];
+      const row: ArtifactRow = [{ type: CellType.ARTIFACT, text: 'tacos' }];
       const handleDisable = jest.fn();
       const handleEnable = jest.fn();
       const { getByType } = render(
@@ -42,8 +41,9 @@ describe('BodyRow', () => {
     });
 
     test('delta cell', () => {
-      const row: Array<BodyCell> = [
-        { type: CellType.DELTA, sizes: { stat: 4 }, percents: { stat: 4 }, hashChanged: false }
+      const row: ArtifactRow = [
+        { type: CellType.ARTIFACT, text: 'tacos' },
+        { name: 'tacos', type: CellType.DELTA, sizes: { stat: 4 }, percents: { stat: 4 }, hashChanged: false }
       ];
       const { getByType } = render(
         <BodyRow
@@ -59,13 +59,16 @@ describe('BodyRow', () => {
         />
       );
       expect(getByType(DeltaCell).props).toMatchObject({
-        cell: row[0],
+        cell: row[1],
         sizeKey: 'stat'
       });
     });
 
     test('total cell', () => {
-      const row: Array<BodyCell> = [{ type: CellType.TOTAL, sizes: { stat: 4 } }];
+      const row: ArtifactRow = [
+        { type: CellType.ARTIFACT, text: 'tacos' },
+        { type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4 } }
+      ];
       const { getByType } = render(
         <BodyRow
           colorScale={ColorScale.Rainbow}
@@ -80,28 +83,7 @@ describe('BodyRow', () => {
         />
       );
       expect(getByType(TotalCell).props).toMatchObject({
-        cell: row[0],
-        sizeKey: 'stat'
-      });
-    });
-
-    test('total delta cell', () => {
-      const row: Array<BodyCell | TDCell> = [{ type: CellType.TOTAL_DELTA, sizes: { stat: 4 }, percents: { stat: 4 } }];
-      const { getByType } = render(
-        <BodyRow
-          colorScale={ColorScale.Rainbow}
-          isActive
-          isHovered={false}
-          onDisableArtifact={jest.fn()}
-          onEnableArtifact={jest.fn()}
-          onHoverArtifact={jest.fn()}
-          row={row}
-          rowIndex={1}
-          sizeKey="stat"
-        />
-      );
-      expect(getByType(TotalDeltaCell).props).toMatchObject({
-        cell: row[0],
+        cell: row[1],
         sizeKey: 'stat'
       });
     });
@@ -109,7 +91,10 @@ describe('BodyRow', () => {
 
   describe('hovering', () => {
     test('is transparent background when row is not isHovered', () => {
-      const row: Array<BodyCell> = [{ type: CellType.TOTAL, sizes: { stat: 4 } }];
+      const row: ArtifactRow = [
+        { type: CellType.ARTIFACT, text: 'tacos' },
+        { type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4 } }
+      ];
       const { getByType } = render(
         <BodyRow
           colorScale={ColorScale.Rainbow}
@@ -129,7 +114,10 @@ describe('BodyRow', () => {
     });
 
     test('is visually highlighed when the row isHovered', () => {
-      const row: Array<BodyCell> = [{ type: CellType.TOTAL, sizes: { stat: 4 } }];
+      const row: ArtifactRow = [
+        { type: CellType.ARTIFACT, text: 'tacos' },
+        { type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4 } }
+      ];
       const { getByType } = render(
         <BodyRow
           colorScale={ColorScale.Rainbow}
@@ -149,7 +137,7 @@ describe('BodyRow', () => {
     });
 
     test('calls back to onHoverArtifact when mouse enter', () => {
-      const row: Array<BodyCell> = [{ type: CellType.ARTIFACT, text: 'tacos' }];
+      const row: ArtifactRow = [{ type: CellType.ARTIFACT, text: 'tacos' }];
       const handleHoverArtifact = jest.fn();
       const { getByType } = render(
         <BodyRow
@@ -169,7 +157,7 @@ describe('BodyRow', () => {
     });
 
     test('calls onHoverArtifact with null if is not active', () => {
-      const row: Array<BodyCell> = [{ type: CellType.ARTIFACT, text: 'tacos' }];
+      const row: ArtifactRow = [{ type: CellType.ARTIFACT, text: 'tacos' }];
       const handleHoverArtifact = jest.fn();
       const { getByType } = render(
         <BodyRow
@@ -187,25 +175,5 @@ describe('BodyRow', () => {
       fireEvent(getByType(Tr), 'mouseEnter');
       expect(handleHoverArtifact).toHaveBeenCalledWith(null);
     });
-
-    test('calls onHoverArtifact with null if is All', () => {
-      const row: Array<BodyCell> = [{ type: CellType.ARTIFACT, text: 'All' }];
-      const handleHoverArtifact = jest.fn();
-      const { getByType } = render(
-        <BodyRow
-          colorScale={ColorScale.Rainbow}
-          isActive
-          isHovered={false}
-          onDisableArtifact={jest.fn()}
-          onEnableArtifact={jest.fn()}
-          onHoverArtifact={handleHoverArtifact}
-          row={row}
-          rowIndex={1}
-          sizeKey="stat"
-        />
-      );
-      fireEvent(getByType(Tr), 'mouseEnter');
-      expect(handleHoverArtifact).toHaveBeenCalledWith(null);
-    });
   });
 });
diff --git a/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx b/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
index 60fb52c..7973680 100644
--- a/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
@@ -1,6 +1,7 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import { BodyRow } from '../BodyRow';
 import Build from '@build-tracker/build';
 import ColorScale from '../../../modules/ColorScale';
 import Comparator from '@build-tracker/comparator';
@@ -22,7 +23,7 @@ const builds = [
 
 describe('ComparisonTable', () => {
   describe('rendering', () => {
-    test('when disabledArtifactsVisible, does not render artifact rows that are disabled', () => {
+    test('when not disabledArtifactsVisible, does not render artifact rows that are disabled', () => {
       const comparator = new Comparator({ builds });
       const { queryAllByProps } = render(
         <ComparisonTable
@@ -30,24 +31,45 @@ describe('ComparisonTable', () => {
           colorScale={ColorScale.Magma}
           comparator={comparator}
           disabledArtifactsVisible={false}
-          hoveredArtifact={null}
-          onDisableArtifact={jest.fn()}
-          onEnableArtifact={jest.fn()}
+          hoveredArtifacts={[]}
+          onDisableArtifacts={jest.fn()}
+          onEnableArtifacts={jest.fn()}
           onFocusRevision={jest.fn()}
-          onHoverArtifact={jest.fn()}
+          onHoverArtifacts={jest.fn()}
           onRemoveRevision={jest.fn()}
           sizeKey="stat"
         />
       );
 
-      expect(queryAllByProps({ cell: comparator.matrixBody[0][0] })).toHaveLength(1);
-      expect(queryAllByProps({ cell: comparator.matrixBody[1][0] })).toHaveLength(0);
+      expect(queryAllByProps({ cell: comparator.matrixArtifacts[0][0] })).toHaveLength(1);
+      expect(queryAllByProps({ cell: comparator.matrixArtifacts[1][0] })).toHaveLength(0);
+    });
+
+    test('when not disabledArtifactsVisible, does not render artifact groups that are disabled', () => {
+      const comparator = new Comparator({ builds, groups: [{ name: 'foobar', artifactNames: ['main', 'vendor'] }] });
+      const { queryAllByProps } = render(
+        <ComparisonTable
+          activeArtifacts={{ vendor: false, main: false }}
+          colorScale={ColorScale.Magma}
+          comparator={comparator}
+          disabledArtifactsVisible={false}
+          hoveredArtifacts={[]}
+          onDisableArtifacts={jest.fn()}
+          onEnableArtifacts={jest.fn()}
+          onFocusRevision={jest.fn()}
+          onHoverArtifacts={jest.fn()}
+          onRemoveRevision={jest.fn()}
+          sizeKey="stat"
+        />
+      );
+
+      expect(queryAllByProps({ cell: comparator.matrixGroups[0][0] })).toHaveLength(0);
     });
   });
 
   describe('artifact toggling', () => {
-    test('artifact off', () => {
-      const handleDisableArtifact = jest.fn();
+    test('artifacts off', () => {
+      const handleDisableArtifacts = jest.fn();
       const comparator = new Comparator({ builds });
       const { getByProps } = render(
         <ComparisonTable
@@ -55,21 +77,21 @@ describe('ComparisonTable', () => {
           colorScale={ColorScale.Magma}
           comparator={comparator}
           disabledArtifactsVisible
-          hoveredArtifact={null}
-          onDisableArtifact={handleDisableArtifact}
-          onEnableArtifact={jest.fn()}
+          hoveredArtifacts={[]}
+          onDisableArtifacts={handleDisableArtifacts}
+          onEnableArtifacts={jest.fn()}
           onFocusRevision={jest.fn()}
-          onHoverArtifact={jest.fn()}
+          onHoverArtifacts={jest.fn()}
           onRemoveRevision={jest.fn()}
           sizeKey="stat"
         />
       );
-      fireEvent(getByProps({ cell: comparator.matrixBody[1][0] }), 'disable', 'main');
-      expect(handleDisableArtifact).toHaveBeenCalledWith('main');
+      fireEvent(getByProps({ cell: comparator.matrixArtifacts[1][0] }), 'disable', 'main');
+      expect(handleDisableArtifacts).toHaveBeenCalledWith(['main']);
     });
 
-    test('artifact on', () => {
-      const handleEnableArtifact = jest.fn();
+    test('artifacts on', () => {
+      const handleEnableArtifacts = jest.fn();
       const comparator = new Comparator({ builds });
       const { getByProps } = render(
         <ComparisonTable
@@ -77,61 +99,63 @@ describe('ComparisonTable', () => {
           colorScale={ColorScale.Magma}
           comparator={comparator}
           disabledArtifactsVisible
-          hoveredArtifact={null}
-          onDisableArtifact={jest.fn()}
-          onEnableArtifact={handleEnableArtifact}
+          hoveredArtifacts={[]}
+          onDisableArtifacts={jest.fn()}
+          onEnableArtifacts={handleEnableArtifacts}
           onFocusRevision={jest.fn()}
-          onHoverArtifact={jest.fn()}
+          onHoverArtifacts={jest.fn()}
           onRemoveRevision={jest.fn()}
           sizeKey="stat"
         />
       );
-      fireEvent(getByProps({ cell: comparator.matrixBody[1][0] }), 'enable', 'vendor');
-      expect(handleEnableArtifact).toHaveBeenCalledWith('vendor');
+      fireEvent(getByProps({ cell: comparator.matrixArtifacts[1][0] }), 'enable', 'vendor');
+      expect(handleEnableArtifacts).toHaveBeenCalledWith(['vendor']);
     });
+  });
 
-    test('all on', () => {
-      const handleEnableArtifact = jest.fn();
+  describe('hovering', () => {
+    test('sets singular artifacts to hovered', () => {
+      const handleHoverArtifacts = jest.fn();
       const comparator = new Comparator({ builds });
-      const { getByProps } = render(
+      const { queryAllByType } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: false }}
           colorScale={ColorScale.Magma}
           comparator={comparator}
           disabledArtifactsVisible
-          hoveredArtifact={null}
-          onDisableArtifact={jest.fn()}
-          onEnableArtifact={handleEnableArtifact}
+          hoveredArtifacts={[]}
+          onDisableArtifacts={jest.fn()}
+          onEnableArtifacts={jest.fn()}
           onFocusRevision={jest.fn()}
-          onHoverArtifact={jest.fn()}
+          onHoverArtifacts={handleHoverArtifacts}
           onRemoveRevision={jest.fn()}
           sizeKey="stat"
         />
       );
-      fireEvent(getByProps({ cell: comparator.matrixBody[0][0] }), 'enable', 'All');
-      expect(handleEnableArtifact).toHaveBeenCalledWith('All');
+      fireEvent(queryAllByType(BodyRow)[0], 'hoverArtifact', 'main');
+      expect(handleHoverArtifacts).toHaveBeenCalledWith(['main']);
     });
-  });
 
-  test('disables hovered artifact on mouse out', () => {
-    const handleHoverArtifact = jest.fn();
-    const comparator = new Comparator({ builds });
-    const { getByType } = render(
-      <ComparisonTable
-        activeArtifacts={{ vendor: false, main: false }}
-        colorScale={ColorScale.Magma}
-        comparator={comparator}
-        disabledArtifactsVisible
-        hoveredArtifact={null}
-        onDisableArtifact={jest.fn()}
-        onEnableArtifact={jest.fn()}
-        onFocusRevision={jest.fn()}
-        onHoverArtifact={handleHoverArtifact}
-        onRemoveRevision={jest.fn()}
-        sizeKey="stat"
-      />
-    );
-    fireEvent(getByType(Table), 'mouseLeave');
-    expect(handleHoverArtifact).toHaveBeenCalledWith(null);
+    test('disables hovered artifact on mouse out', () => {
+      const handleHoverArtifacts = jest.fn();
+      const comparator = new Comparator({ builds });
+      const { getByType } = render(
+        <ComparisonTable
+          activeArtifacts={{ vendor: false, main: false }}
+          colorScale={ColorScale.Magma}
+          comparator={comparator}
+          disabledArtifactsVisible
+          hoveredArtifacts={[]}
+          onDisableArtifacts={jest.fn()}
+          onEnableArtifacts={jest.fn()}
+          onFocusRevision={jest.fn()}
+          onHoverArtifacts={handleHoverArtifacts}
+          onRemoveRevision={jest.fn()}
+          sizeKey="stat"
+        />
+      );
+      fireEvent(getByType(Table), 'mouseLeave');
+      expect(handleHoverArtifacts).toHaveBeenCalledWith([]);
+    });
   });
 });
diff --git a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
index 3d8a3ea..d56f809 100644
--- a/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/DeltaCell.test.tsx
@@ -13,7 +13,13 @@ describe('DeltaCell', () => {
     test('is a formatted value', () => {
       const { queryAllByText } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { stat: 0.5 }, hashChanged: true, sizes: { stat: 4300 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { stat: 0.5 },
+            hashChanged: true,
+            sizes: { stat: 4300 }
+          }}
           sizeKey="stat"
         />
       );
@@ -23,7 +29,7 @@ describe('DeltaCell', () => {
     test('is empty string if value is zero', () => {
       const { queryAllByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { stat: 0 }, hashChanged: false, sizes: { stat: 0 } }}
+          cell={{ type: CellType.DELTA, name: 'tacos', percents: { stat: 0 }, hashChanged: false, sizes: { stat: 0 } }}
           sizeKey="stat"
         />
       );
@@ -33,7 +39,13 @@ describe('DeltaCell', () => {
     test('shows formatted bytes and delta in the title', () => {
       const { getByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { stat: -0.5 }, hashChanged: true, sizes: { stat: -134 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { stat: -0.5 },
+            hashChanged: true,
+            sizes: { stat: -134 }
+          }}
           sizeKey="stat"
         />
       );
@@ -44,7 +56,7 @@ describe('DeltaCell', () => {
     test('shows a warning label if no change, but hash changed', () => {
       const { getByType, queryAllByText } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { stat: 0 }, hashChanged: true, sizes: { stat: 0 } }}
+          cell={{ type: CellType.DELTA, name: 'tacos', percents: { stat: 0 }, hashChanged: true, sizes: { stat: 0 } }}
           sizeKey="stat"
         />
       );
@@ -57,7 +69,13 @@ describe('DeltaCell', () => {
     test('is green for reductions', () => {
       const { getByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: -1 }, hashChanged: true, sizes: { gzip: -4300 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { gzip: -1 },
+            hashChanged: true,
+            sizes: { gzip: -4300 }
+          }}
           sizeKey="gzip"
         />
       );
@@ -69,7 +87,13 @@ describe('DeltaCell', () => {
     test('is red for increases', () => {
       const { getByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: 0.9 }, hashChanged: true, sizes: { gzip: 4300 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { gzip: 0.9 },
+            hashChanged: true,
+            sizes: { gzip: 4300 }
+          }}
           sizeKey="gzip"
         />
       );
@@ -81,7 +105,7 @@ describe('DeltaCell', () => {
     test('is red if no size change', () => {
       const { getByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: 0 }, hashChanged: true, sizes: { gzip: 0 } }}
+          cell={{ type: CellType.DELTA, name: 'tacos', percents: { gzip: 0 }, hashChanged: true, sizes: { gzip: 0 } }}
           sizeKey="gzip"
         />
       );
@@ -93,7 +117,7 @@ describe('DeltaCell', () => {
     test('is transparent for no change', () => {
       const { getByType } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: 0 }, hashChanged: false, sizes: { gzip: 0 } }}
+          cell={{ type: CellType.DELTA, name: 'tacos', percents: { gzip: 0 }, hashChanged: false, sizes: { gzip: 0 } }}
           sizeKey="gzip"
         />
       );
@@ -105,7 +129,13 @@ describe('DeltaCell', () => {
     test('mouse enter shows a tooltip', () => {
       const { getByTestId, queryAllByProps } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: 1 }, hashChanged: false, sizes: { gzip: 1024 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { gzip: 1 },
+            hashChanged: false,
+            sizes: { gzip: 1024 }
+          }}
           sizeKey="gzip"
         />
       );
@@ -116,7 +146,13 @@ describe('DeltaCell', () => {
     test('mouse leave removes the tooltip', () => {
       const { getByTestId, queryAllByProps } = render(
         <DeltaCell
-          cell={{ type: CellType.DELTA, percents: { gzip: 1 }, hashChanged: false, sizes: { gzip: 1024 } }}
+          cell={{
+            type: CellType.DELTA,
+            name: 'tacos',
+            percents: { gzip: 1 },
+            hashChanged: false,
+            sizes: { gzip: 1024 }
+          }}
           sizeKey="gzip"
         />
       );
diff --git a/src/app/src/components/ComparisonTable/__tests__/GroupCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/GroupCell.test.tsx
new file mode 100644
index 0000000..9c647ba
--- /dev/null
+++ b/src/app/src/components/ComparisonTable/__tests__/GroupCell.test.tsx
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { CellType } from '@build-tracker/comparator';
+import { GroupCell } from '../GroupCell';
+import React from 'react';
+import { Switch } from 'react-native';
+import { fireEvent, render } from 'react-native-testing-library';
+
+describe('GroupCell', () => {
+  describe('folder', () => {
+    test('mouse enter shows a tooltip', () => {
+      const { getByTestId, queryAllByProps } = render(
+        <GroupCell
+          cell={{ type: CellType.GROUP, text: 'tacos', artifactNames: ['main'] }}
+          isActive={false}
+          onDisable={jest.fn()}
+          onEnable={jest.fn()}
+        />
+      );
+      fireEvent(getByTestId('groupicon'), 'mouseEnter');
+      expect(queryAllByProps({ accessibilityRole: 'tooltip' })).toHaveLength(1);
+    });
+
+    test('mouse leave removes the tooltip', () => {
+      const { getByTestId, queryAllByProps } = render(
+        <GroupCell
+          cell={{ type: CellType.GROUP, text: 'tacos', artifactNames: ['main'] }}
+          isActive={false}
+          onDisable={jest.fn()}
+          onEnable={jest.fn()}
+        />
+      );
+      fireEvent(getByTestId('groupicon'), 'mouseEnter');
+      fireEvent(getByTestId('groupicon'), 'mouseLeave');
+      expect(queryAllByProps({ accessibilityRole: 'tooltip' })).toHaveLength(0);
+    });
+  });
+
+  describe('switch', () => {
+    test('toggles with the name and on', () => {
+      const handleDisable = jest.fn();
+      const handleEnable = jest.fn();
+      const { getByType } = render(
+        <GroupCell
+          cell={{ type: CellType.GROUP, text: 'tacos', artifactNames: ['main'] }}
+          isActive={false}
+          onDisable={handleDisable}
+          onEnable={handleEnable}
+        />
+      );
+      fireEvent(getByType(Switch), 'valueChange', true);
+      expect(handleEnable).toHaveBeenCalledWith(['main']);
+    });
+
+    test('toggles with the name and off', () => {
+      const handleDisable = jest.fn();
+      const handleEnable = jest.fn();
+      const { getByType } = render(
+        <GroupCell
+          cell={{ type: CellType.GROUP, text: 'tacos', artifactNames: ['main'] }}
+          isActive
+          onDisable={handleDisable}
+          onEnable={handleEnable}
+        />
+      );
+      fireEvent(getByType(Switch), 'valueChange', false);
+      expect(handleDisable).toHaveBeenCalledWith(['main']);
+    });
+  });
+});
diff --git a/src/app/src/components/ComparisonTable/__tests__/GroupRow.test.tsx b/src/app/src/components/ComparisonTable/__tests__/GroupRow.test.tsx
new file mode 100644
index 0000000..41363af
--- /dev/null
+++ b/src/app/src/components/ComparisonTable/__tests__/GroupRow.test.tsx
@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { GroupCell } from '../GroupCell';
+import { GroupRow } from '../GroupRow';
+import React from 'react';
+import { TotalCell } from '../TotalCell';
+import { TotalDeltaCell } from '../TotalDeltaCell';
+import { Tr } from '../../Table';
+import { CellType, GroupRow as GRow } from '@build-tracker/comparator';
+import { fireEvent, render } from 'react-native-testing-library';
+
+describe('GroupRow', () => {
+  describe('render', () => {
+    test('artifact cell', () => {
+      const row: GRow = [{ type: CellType.GROUP, artifactNames: ['tacos', 'burritos'], text: 'tacos' }];
+      const handleDisable = jest.fn();
+      const handleEnable = jest.fn();
+      const { getByType } = render(
+        <GroupRow
+          isActive
+          onDisable={handleDisable}
+          onEnable={handleEnable}
+          onHover={jest.fn()}
+          row={row}
+          sizeKey="stat"
+        />
+      );
+      expect(getByType(GroupCell).props).toMatchObject({
+        cell: row[0],
+        isActive: true,
+        onDisable: handleDisable,
+        onEnable: handleEnable
+      });
+    });
+
+    test('total cell', () => {
+      const row: GRow = [
+        { type: CellType.GROUP, artifactNames: ['tacos', 'burritos'], text: 'tacos' },
+        { type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4 } }
+      ];
+      const { getByType } = render(
+        <GroupRow isActive onDisable={jest.fn()} onEnable={jest.fn()} onHover={jest.fn()} row={row} sizeKey="stat" />
+      );
+      expect(getByType(TotalCell).props).toMatchObject({
+        cell: row[1],
+        sizeKey: 'stat'
+      });
+    });
+
+    test('total delta cell', () => {
+      const row: GRow = [
+        { type: CellType.GROUP, artifactNames: ['tacos', 'burritos'], text: 'tacos' },
+        { type: CellType.TOTAL_DELTA, sizes: { stat: 4 }, percents: { stat: 4 } }
+      ];
+      const { getByType } = render(
+        <GroupRow isActive onDisable={jest.fn()} onEnable={jest.fn()} onHover={jest.fn()} row={row} sizeKey="stat" />
+      );
+      expect(getByType(TotalDeltaCell).props).toMatchObject({
+        cell: row[1],
+        sizeKey: 'stat'
+      });
+    });
+  });
+
+  describe('hovering', () => {
+    test('calls back to onHoverArtifact when mouse enter', () => {
+      const row: GRow = [{ type: CellType.GROUP, artifactNames: ['tacos', 'burritos'], text: 'tacos' }];
+      const handleHoverArtifacts = jest.fn();
+      const { getByType } = render(
+        <GroupRow
+          isActive
+          onDisable={jest.fn()}
+          onEnable={jest.fn()}
+          onHover={handleHoverArtifacts}
+          row={row}
+          sizeKey="stat"
+        />
+      );
+      fireEvent(getByType(Tr), 'mouseEnter');
+      expect(handleHoverArtifacts).toHaveBeenCalledWith(['tacos', 'burritos']);
+    });
+
+    test('calls onHoverArtifact with empty array if is not active', () => {
+      const row: GRow = [{ type: CellType.GROUP, artifactNames: ['tacos', 'burritos'], text: 'tacos' }];
+      const handleHoverArtifacts = jest.fn();
+      const { getByType } = render(
+        <GroupRow
+          isActive={false}
+          onDisable={jest.fn()}
+          onEnable={jest.fn()}
+          onHover={handleHoverArtifacts}
+          row={row}
+          sizeKey="stat"
+        />
+      );
+      fireEvent(getByType(Tr), 'mouseEnter');
+      expect(handleHoverArtifacts).toHaveBeenCalledWith([]);
+    });
+  });
+});
diff --git a/src/app/src/components/ComparisonTable/__tests__/HeaderRow.test.tsx b/src/app/src/components/ComparisonTable/__tests__/HeaderRow.test.tsx
index 3ed454b..548e1a9 100644
--- a/src/app/src/components/ComparisonTable/__tests__/HeaderRow.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/HeaderRow.test.tsx
@@ -7,12 +7,12 @@ import { render } from 'react-native-testing-library';
 import { RevisionCell } from '../RevisionCell';
 import { RevisionDeltaCell } from '../RevisionDeltaCell';
 import { TextCell } from '../TextCell';
-import { CellType, HeaderCell } from '@build-tracker/comparator';
+import { CellType, HeaderRow as HRow } from '@build-tracker/comparator';
 
 describe('HeaderRow', () => {
   describe('render', () => {
     test('text cell', () => {
-      const row: Array<HeaderCell> = [{ type: CellType.TEXT, text: 'tacos' }];
+      const row: HRow = [{ type: CellType.TEXT, text: 'tacos' }];
       const { getByType } = render(<HeaderRow onFocusRevision={jest.fn()} onRemoveRevision={jest.fn()} row={row} />);
       expect(getByType(TextCell).props).toMatchObject({
         cell: row[0],
@@ -22,14 +22,14 @@ describe('HeaderRow', () => {
     });
 
     test('revision cell', () => {
-      const row: Array<HeaderCell> = [{ type: CellType.REVISION, revision: 'tacos' }];
+      const row: HRow = [{ type: CellType.TEXT, text: 'tacos' }, { type: CellType.REVISION, revision: 'asdf' }];
       const handleFocusRevision = jest.fn();
       const handleRemoveRevision = jest.fn();
       const { getByType } = render(
         <HeaderRow onFocusRevision={handleFocusRevision} onRemoveRevision={handleRemoveRevision} row={row} />
       );
       expect(getByType(RevisionCell).props).toMatchObject({
-        cell: row[0],
+        cell: row[1],
         onFocus: handleFocusRevision,
         onRemove: handleRemoveRevision,
         style: expect.objectContaining({ backgroundColor: 'white', position: 'sticky', top: 0, zIndex: 4 })
@@ -37,12 +37,13 @@ describe('HeaderRow', () => {
     });
 
     test('revision delta cell', () => {
-      const row: Array<HeaderCell> = [
+      const row: HRow = [
+        { type: CellType.TEXT, text: 'tacos' },
         { type: CellType.REVISION_DELTA, revision: 'tacos', deltaIndex: 1, againstRevision: 'burritos' }
       ];
       const { getByType } = render(<HeaderRow onFocusRevision={jest.fn()} onRemoveRevision={jest.fn()} row={row} />);
       expect(getByType(RevisionDeltaCell).props).toMatchObject({
-        cell: row[0],
+        cell: row[1],
         style: expect.objectContaining({ backgroundColor: 'white', position: 'sticky', top: 0, zIndex: 4 })
       });
     });
diff --git a/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx b/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
index 4aeff64..a011097 100644
--- a/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/TotalCell.test.tsx
@@ -10,13 +10,15 @@ import { TotalCell } from '../TotalCell';
 describe('TotalCell', () => {
   test('Displays a formatted value', () => {
     const { queryAllByText } = render(
-      <TotalCell cell={{ type: CellType.TOTAL, sizes: { stat: 4300 } }} sizeKey="stat" />
+      <TotalCell cell={{ type: CellType.TOTAL, name: 'tacos', sizes: { stat: 4300 } }} sizeKey="stat" />
     );
     expect(queryAllByText('4.2 KiB')).toHaveLength(1);
   });
 
   test('Displays empty string if value is zero', () => {
-    const { queryAllByType } = render(<TotalCell cell={{ type: CellType.TOTAL, sizes: { stat: 0 } }} sizeKey="stat" />);
+    const { queryAllByType } = render(
+      <TotalCell cell={{ type: CellType.TOTAL, name: 'tacos', sizes: { stat: 0 } }} sizeKey="stat" />
+    );
     expect(queryAllByType(Text)).toHaveLength(0);
   });
 });
diff --git a/src/app/src/components/Graph/__tests__/Area.test.tsx b/src/app/src/components/Graph/__tests__/Area.test.tsx
index 62029c6..d9890d9 100644
--- a/src/app/src/components/Graph/__tests__/Area.test.tsx
+++ b/src/app/src/components/Graph/__tests__/Area.test.tsx
@@ -46,7 +46,7 @@ describe('Area', () => {
           colorScale={ColorScale.Rainbow}
           comparator={comparator}
           data={data}
-          hoveredArtifact={null}
+          hoveredArtifacts={[]}
           xScale={xScale}
           yScale={yScale}
         />
@@ -91,7 +91,7 @@ describe('Area', () => {
           colorScale={ColorScale.Rainbow}
           comparator={comparator}
           data={data}
-          hoveredArtifact={null}
+          hoveredArtifacts={[]}
           xScale={xScale}
           yScale={yScale}
         />
@@ -140,7 +140,7 @@ describe('Area', () => {
           colorScale={ColorScale.Rainbow}
           comparator={comparator}
           data={data}
-          hoveredArtifact={'vendor'}
+          hoveredArtifacts={['vendor']}
           xScale={xScale}
           yScale={yScale}
         />
diff --git a/src/app/src/components/Graph/__tests__/HoverOverlay.test.tsx b/src/app/src/components/Graph/__tests__/HoverOverlay.test.tsx
index a99646a..e916191 100644
--- a/src/app/src/components/Graph/__tests__/HoverOverlay.test.tsx
+++ b/src/app/src/components/Graph/__tests__/HoverOverlay.test.tsx
@@ -53,7 +53,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -76,7 +76,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -103,7 +103,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -136,7 +136,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -154,13 +154,13 @@ describe('HoverOverlay', () => {
     });
 
     test('calls back with the hovered artifact', () => {
-      const handleHoverArtifact = jest.fn();
+      const handleHoverArtifacts = jest.fn();
       const { getByTestId } = render(
         <svg>
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={handleHoverArtifact}
+            onHoverArtifacts={handleHoverArtifacts}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -173,11 +173,11 @@ describe('HoverOverlay', () => {
         fireEvent.mouseMove(getByTestId('hoveroverlay'));
       });
 
-      expect(handleHoverArtifact).toHaveBeenCalledWith('vendor');
+      expect(handleHoverArtifacts).toHaveBeenCalledWith(['vendor']);
     });
 
     test('calls back with null if no artifact', () => {
-      const handleHoverArtifact = jest.fn();
+      const handleHoverArtifacts = jest.fn();
       const yScale = scaleLinear()
         .range([400, 0])
         .domain([0, 200]);
@@ -186,7 +186,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={handleHoverArtifact}
+            onHoverArtifacts={handleHoverArtifacts}
             onSelectRevision={jest.fn()}
             selectedRevisions={[]}
             width={300}
@@ -199,7 +199,7 @@ describe('HoverOverlay', () => {
         fireEvent.mouseMove(getByTestId('hoveroverlay'));
       });
 
-      expect(handleHoverArtifact).toHaveBeenCalledWith(null);
+      expect(handleHoverArtifacts).toHaveBeenCalledWith([]);
     });
   });
 
@@ -211,7 +211,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={handleSelectRevision}
             selectedRevisions={[]}
             width={300}
@@ -234,7 +234,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={handleSelectRevision}
             selectedRevisions={['1234']}
             width={300}
@@ -256,7 +256,7 @@ describe('HoverOverlay', () => {
           <HoverOverlay
             data={data}
             height={400}
-            onHoverArtifact={jest.fn()}
+            onHoverArtifacts={jest.fn()}
             onSelectRevision={jest.fn()}
             selectedRevisions={['abcdefg1234567', 'abcd']}
             width={300}
diff --git a/src/app/src/components/Graph/__tests__/index.test.tsx b/src/app/src/components/Graph/__tests__/index.test.tsx
index f4e655c..93b65b7 100644
--- a/src/app/src/components/Graph/__tests__/index.test.tsx
+++ b/src/app/src/components/Graph/__tests__/index.test.tsx
@@ -27,8 +27,8 @@ describe('Graph', () => {
         activeArtifacts={{ main: true }}
         colorScale={ColorScale.Magma}
         comparator={comparator}
-        hoveredArtifact={null}
-        onHoverArtifact={jest.fn()}
+        hoveredArtifacts={[]}
+        onHoverArtifacts={jest.fn()}
         onSelectRevision={jest.fn()}
         selectedRevisions={[]}
         sizeKey="stat"
diff --git a/src/app/src/screens/__tests__/Main.test.tsx b/src/app/src/screens/__tests__/Main.test.tsx
index a1a81e9..644a6c4 100644
--- a/src/app/src/screens/__tests__/Main.test.tsx
+++ b/src/app/src/screens/__tests__/Main.test.tsx
@@ -67,27 +67,14 @@ describe('Main', () => {
   });
 
   describe('artifacts', () => {
-    test('can disable all artifacts', async () => {
+    test('can disable a artifacts', async () => {
       const { getByType } = render(<Main />);
       act(() => {
         fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
       });
       await flushMicrotasksQueue(); // ensure dynamic imports are loaded
       act(() => {
-        fireEvent(getByType(Comparison), 'disableArtifact', 'All');
-      });
-      expect(Object.values(getByType(Comparison).props.activeArtifacts)).not.toEqual(expect.arrayContaining([true]));
-      expect(Object.values(getByType(Graph).props.activeArtifacts)).not.toEqual(expect.arrayContaining([true]));
-    });
-
-    test('can disable a single artifact', async () => {
-      const { getByType } = render(<Main />);
-      act(() => {
-        fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      });
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      act(() => {
-        fireEvent(getByType(Comparison), 'disableArtifact', 'main');
+        fireEvent(getByType(Comparison), 'disableArtifacts', ['main']);
       });
       expect(getByType(Comparison).props.activeArtifacts).toMatchObject({
         main: false,
@@ -97,29 +84,15 @@ describe('Main', () => {
       expect(getByType(Graph).props.activeArtifacts).toMatchObject({ main: false, vendor: true, shared: true });
     });
 
-    test('can enable all artifacts', async () => {
-      const { getByType } = render(<Main />);
-      act(() => {
-        fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      });
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      act(() => {
-        fireEvent(getByType(Comparison), 'disableArtifact', 'All');
-        fireEvent(getByType(Comparison), 'enableArtifact', 'All');
-      });
-      expect(Object.values(getByType(Comparison).props.activeArtifacts)).not.toEqual(expect.arrayContaining([false]));
-      expect(Object.values(getByType(Graph).props.activeArtifacts)).not.toEqual(expect.arrayContaining([false]));
-    });
-
-    test('can enable a single artifact', async () => {
+    test('can enable a artifacts', async () => {
       const { getByType } = render(<Main />);
       act(() => {
         fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
       });
       await flushMicrotasksQueue(); // ensure dynamic imports are loaded
       act(() => {
-        fireEvent(getByType(Comparison), 'disableArtifact', 'All');
-        fireEvent(getByType(Comparison), 'enableArtifact', 'main');
+        fireEvent(getByType(Comparison), 'disableArtifacts', ['main', 'vendor', 'shared']);
+        fireEvent(getByType(Comparison), 'enableArtifacts', ['main']);
       });
       expect(getByType(Comparison).props.activeArtifacts).toMatchObject({
         main: true,
diff --git a/src/build/src/__tests__/index.test.ts b/src/build/src/__tests__/index.test.ts
index 7b20d45..91f36b3 100644
--- a/src/build/src/__tests__/index.test.ts
+++ b/src/build/src/__tests__/index.test.ts
@@ -81,6 +81,32 @@ describe('Build', () => {
     });
   });
 
+  describe('getSum', () => {
+    test('gets a sum of the given artifact names', () => {
+      const build = new Build(baseMeta, [
+        tacoArtifact,
+        burritoArtifact,
+        { name: 'churros', hash: 'abc', sizes: { stat: 6, gzip: 4 } }
+      ]);
+      expect(build.getSum(['churros', 'burritos'])).toEqual({
+        stat: 9,
+        gzip: 6
+      });
+    });
+
+    test('gets a sum even if an artifact does not exist', () => {
+      const build = new Build(baseMeta, [
+        tacoArtifact,
+        burritoArtifact,
+        { name: 'churros', hash: 'abc', sizes: { stat: 6, gzip: 4 } }
+      ]);
+      expect(build.getSum(['nachos', 'burritos'])).toEqual({
+        stat: 3,
+        gzip: 2
+      });
+    });
+  });
+
   describe('getTotals', () => {
     test('gets the computed totals for all sizes', () => {
       const build = new Build(baseMeta, [tacoArtifact, burritoArtifact]);
diff --git a/src/comparator/src/__tests__/BuildDelta.test.ts b/src/comparator/src/__tests__/BuildDelta.test.ts
index fb0dcab..4597ff9 100644
--- a/src/comparator/src/__tests__/BuildDelta.test.ts
+++ b/src/comparator/src/__tests__/BuildDelta.test.ts
@@ -20,8 +20,8 @@ describe('BuildDelta', () => {
       { revision: { value: '456', url: 'https://build-tracker.local' }, parentRevision: 'abc', timestamp: Date.now() },
       [
         { name: 'tacos', hash: '123', sizes: { stat: 1, gzip: 1 } },
-        { name: 'burritos', hash: '123', sizes: { stat: 6, gzip: 4 } },
-        { name: 'churros', hash: '123', sizes: { stat: 6, gzip: 4 } }
+        { name: 'burritos', hash: 'abc', sizes: { stat: 6, gzip: 4 } },
+        { name: 'churros', hash: 'abc', sizes: { stat: 6, gzip: 4 } }
       ]
     );
   });
@@ -69,7 +69,7 @@ describe('BuildDelta', () => {
     });
 
     test('filters out filtered artifact names', () => {
-      const bd = new BuildDelta(buildA, buildB, [/churros/]);
+      const bd = new BuildDelta(buildA, buildB, { artifactFilters: [/churros/] });
       expect(Array.from(bd.artifactNames)).toEqual(['tacos', 'burritos']);
     });
   });
@@ -79,6 +79,7 @@ describe('BuildDelta', () => {
       const bd = new BuildDelta(buildA, buildB);
       expect(bd.artifactDeltas).toEqual([
         {
+          budgets: [],
           hashChanged: false,
           name: 'tacos',
           sizes: {
@@ -91,7 +92,8 @@ describe('BuildDelta', () => {
           }
         },
         {
-          hashChanged: false,
+          budgets: [],
+          hashChanged: true,
           name: 'burritos',
           sizes: {
             gzip: -2,
@@ -103,6 +105,7 @@ describe('BuildDelta', () => {
           }
         },
         {
+          budgets: [],
           hashChanged: true,
           name: 'churros',
           sizes: {
@@ -121,6 +124,7 @@ describe('BuildDelta', () => {
       const bd = new BuildDelta(buildB, buildA);
       expect(bd.artifactDeltas).toEqual([
         {
+          budgets: [],
           hashChanged: false,
           name: 'tacos',
           sizes: {
@@ -133,7 +137,8 @@ describe('BuildDelta', () => {
           }
         },
         {
-          hashChanged: false,
+          budgets: [],
+          hashChanged: true,
           name: 'burritos',
           sizes: {
             gzip: 2,
@@ -145,6 +150,7 @@ describe('BuildDelta', () => {
           }
         },
         {
+          budgets: [],
           hashChanged: true,
           name: 'churros',
           sizes: {
@@ -160,9 +166,10 @@ describe('BuildDelta', () => {
     });
 
     test('respects artifact filters', () => {
-      const bd = new BuildDelta(buildA, buildB, [/churros/, /burritos/]);
+      const bd = new BuildDelta(buildA, buildB, { artifactFilters: [/churros/, /burritos/] });
       expect(bd.artifactDeltas).toEqual([
         {
+          budgets: [],
           hashChanged: false,
           name: 'tacos',
           sizes: {
@@ -182,6 +189,7 @@ describe('BuildDelta', () => {
     test('gets the delta for a single artifact', () => {
       const bd = new BuildDelta(buildA, buildB);
       expect(bd.getArtifactDelta('tacos')).toEqual({
+        budgets: [],
         hashChanged: false,
         name: 'tacos',
         sizes: {
@@ -194,6 +202,170 @@ describe('BuildDelta', () => {
         }
       });
     });
+
+    test('includes passing budgets', () => {
+      const bd = new BuildDelta(buildA, buildB, {
+        artifactBudgets: {
+          burritos: [{ level: 'error', sizeKey: 'gzip', type: 'size', maximum: 5 }]
+        }
+      });
+      expect(bd.getArtifactDelta('burritos')).toMatchObject({
+        budgets: [
+          {
+            passing: true,
+            expected: 5,
+            actual: 2,
+            type: 'size',
+            level: 'error'
+          }
+        ]
+      });
+    });
+
+    test('includes failing budgets for size', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        artifactBudgets: {
+          burritos: [{ level: 'error', sizeKey: 'stat', type: 'size', maximum: 5 }]
+        }
+      });
+      expect(bd.getArtifactDelta('burritos')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 5,
+            actual: 6,
+            type: 'size',
+            level: 'error'
+          }
+        ]
+      });
+    });
+
+    test('includes failing budgets for delta', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        artifactBudgets: {
+          burritos: [{ level: 'error', sizeKey: 'gzip', type: 'delta', maximum: 2 }]
+        }
+      });
+      expect(bd.getArtifactDelta('burritos')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 2,
+            actual: 2,
+            type: 'delta',
+            level: 'error'
+          }
+        ]
+      });
+    });
+
+    test('includes failing budgets for percentDelta', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        artifactBudgets: {
+          burritos: [{ level: 'error', sizeKey: 'stat', type: 'percentDelta', maximum: 0.5 }]
+        }
+      });
+      expect(bd.getArtifactDelta('burritos')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 0.5,
+            actual: 1,
+            type: 'percentDelta',
+            level: 'error'
+          }
+        ]
+      });
+    });
+  });
+
+  describe('getGroupDelta', () => {
+    test('gets the delta for a defined group', () => {
+      const bd = new BuildDelta(buildA, buildB, { groups: [{ name: 'stuff', artifactNames: ['burritos', 'tacos'] }] });
+      expect(bd.getGroupDelta('stuff')).toEqual({
+        budgets: [],
+        hashChanged: true,
+        name: 'stuff',
+        percents: {
+          gzip: -0.4,
+          stat: -0.2857142857142857
+        },
+        sizes: {
+          gzip: -2,
+          stat: -2
+        }
+      });
+    });
+
+    test('includes failing budgets for size', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        groups: [
+          {
+            name: 'foo',
+            artifactNames: ['burritos', 'tacos'],
+            budgets: [{ level: 'error', sizeKey: 'stat', type: 'size', maximum: 5 }]
+          }
+        ]
+      });
+      expect(bd.getGroupDelta('foo')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 5,
+            actual: 7,
+            type: 'size',
+            level: 'error'
+          }
+        ]
+      });
+    });
+
+    test('includes failing budgets for delta', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        groups: [
+          {
+            name: 'foo',
+            artifactNames: ['burritos', 'tacos'],
+            budgets: [{ level: 'error', sizeKey: 'gzip', type: 'delta', maximum: 2 }]
+          }
+        ]
+      });
+      expect(bd.getGroupDelta('foo')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 2,
+            actual: 2,
+            type: 'delta',
+            level: 'error'
+          }
+        ]
+      });
+    });
+
+    test('includes failing budgets for percentDelta', () => {
+      const bd = new BuildDelta(buildB, buildA, {
+        groups: [
+          {
+            name: 'foo',
+            artifactNames: ['burritos', 'tacos'],
+            budgets: [{ level: 'error', sizeKey: 'stat', type: 'percentDelta', maximum: 0.2 }]
+          }
+        ]
+      });
+      expect(bd.getGroupDelta('foo')).toMatchObject({
+        budgets: [
+          {
+            passing: false,
+            expected: 0.2,
+            actual: 0.4,
+            type: 'percentDelta',
+            level: 'error'
+          }
+        ]
+      });
+    });
   });
 
   describe('totalDelta', () => {
@@ -213,7 +385,7 @@ describe('BuildDelta', () => {
     });
 
     test('gets the total delta of all assets added together, without filtered artifacts', () => {
-      const bd = new BuildDelta(buildA, buildB, [/burritos/]);
+      const bd = new BuildDelta(buildA, buildB, { artifactFilters: [/burritos/] });
       expect(bd.totalDelta).toEqual({
         againstRevision: '456',
         sizes: {
diff --git a/src/comparator/src/__tests__/index.test.ts b/src/comparator/src/__tests__/index.test.ts
index 90fb3a5..f04f7b9 100644
--- a/src/comparator/src/__tests__/index.test.ts
+++ b/src/comparator/src/__tests__/index.test.ts
@@ -3,7 +3,14 @@
  */
 import Build from '@build-tracker/build';
 import BuildDelta from '../BuildDelta';
-import BuildComparator, { BodyCell, DeltaCell, RevisionCell, RevisionDeltaCell, TotalCell, TotalDeltaCell } from '..';
+import BuildComparator, {
+  ArtifactRow,
+  DeltaCell,
+  RevisionCell,
+  RevisionDeltaCell,
+  TotalCell,
+  TotalDeltaCell
+} from '..';
 
 const build1 = new Build({ revision: '1234567abcdef', parentRevision: 'abcdef', timestamp: 1234567 }, [
   { name: 'burritos', hash: 'abc', sizes: { stat: 456, gzip: 90 } },
@@ -40,10 +47,10 @@ describe('BuildComparator', () => {
       expect(comparator.artifactNames).toEqual(['tacos']);
     });
 
-    test('filters artifacts from the matrixBody', () => {
+    test('filters artifacts from the matrixArtifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2], artifactFilters });
-      expect(comparator.matrixBody).toHaveLength(1);
-      expect(comparator.matrixBody[0][0]).toMatchObject({ text: 'tacos' });
+      expect(comparator.matrixArtifacts).toHaveLength(1);
+      expect(comparator.matrixArtifacts[0][0]).toMatchObject({ text: 'tacos' });
     });
   });
 
@@ -155,8 +162,9 @@ describe('BuildComparator', () => {
     test('includes an artifact cell for All', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
       expect(comparator.matrixTotal[0]).toEqual({
-        type: 'artifact',
-        text: 'All'
+        type: 'group',
+        text: 'All',
+        artifactNames: ['churros', 'burritos', 'tacos']
       });
     });
 
@@ -182,10 +190,10 @@ describe('BuildComparator', () => {
     });
   });
 
-  describe('matrixBody', () => {
+  describe('matrixArtifacts', () => {
     test('includes a row for each artifact', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      expect(comparator.matrixBody.map(r => r[0])).toEqual([
+      expect(comparator.matrixArtifacts.map(r => r[0])).toEqual([
         { type: 'artifact', text: 'churros' },
         { type: 'artifact', text: 'burritos' },
         { type: 'artifact', text: 'tacos' }
@@ -194,23 +202,21 @@ describe('BuildComparator', () => {
 
     test('includes totals for each artifact', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      expect(comparator.matrixBody[2][1]).toEqual({
+      expect(comparator.matrixArtifacts[2][1]).toEqual({
         type: 'total',
         name: 'tacos',
-        hash: 'abc',
         sizes: { gzip: 45, stat: 123 }
       });
-      expect(comparator.matrixBody[2][2]).toEqual({
+      expect(comparator.matrixArtifacts[2][2]).toEqual({
         type: 'total',
         name: 'tacos',
-        hash: 'abc',
         sizes: { gzip: 43, stat: 123 }
       });
     });
 
     test('includes deltas for each artifact', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      expect(comparator.matrixBody[2][3]).toEqual({
+      expect(comparator.matrixArtifacts[2][3]).toMatchObject({
         type: 'delta',
         hashChanged: false,
         name: 'tacos',
@@ -220,6 +226,40 @@ describe('BuildComparator', () => {
     });
   });
 
+  describe('matrixGroups', () => {
+    test('includes totals for each group', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        groups: [{ name: 'stuff', artifactNames: ['churros', 'burritos'] }]
+      });
+      expect(comparator.matrixGroups[0][1]).toEqual({
+        type: 'total',
+        name: 'stuff',
+        sizes: { gzip: 90, stat: 456 }
+      });
+      expect(comparator.matrixGroups[0][2]).toEqual({
+        type: 'total',
+        name: 'stuff',
+        sizes: { gzip: 120, stat: 469 }
+      });
+    });
+
+    test('includes deltas for each group', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        groups: [{ name: 'stuff', artifactNames: ['churros', 'burritos'] }]
+      });
+      expect(comparator.matrixGroups[0][3]).toEqual({
+        type: 'totalDelta',
+        name: 'stuff',
+        hashChanged: true,
+        budgets: [],
+        percents: { gzip: 0.3333333333333333, stat: 0.02850877192982456 },
+        sizes: { gzip: 30, stat: 13 }
+      });
+    });
+  });
+
   describe('toJSON', () => {
     test('includes the header', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
@@ -231,9 +271,17 @@ describe('BuildComparator', () => {
       expect(comparator.toJSON().total).toBe(comparator.matrixTotal);
     });
 
-    test('includes the body', () => {
+    test('includes the groups', () => {
+      const comparator = new BuildComparator({
+        builds: [build1, build2],
+        groups: [{ name: 'yum', artifactNames: ['burritos', 'tacos'] }]
+      });
+      expect(comparator.toJSON().groups).toBe(comparator.matrixGroups);
+    });
+
+    test('includes the artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      expect(comparator.toJSON().body).toBe(comparator.matrixBody);
+      expect(comparator.toJSON().artifacts).toBe(comparator.matrixArtifacts);
     });
   });
 
@@ -262,9 +310,9 @@ describe('BuildComparator', () => {
 `);
     });
 
-    test('can filter rows', () => {
+    test('can filter artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      const rowFilter = (row): boolean => {
+      const artifactFilter = (row): boolean => {
         return row.some(cell => {
           if (cell.sizes && 'gzip' in cell.sizes) {
             return cell.sizes.gzip > 50;
@@ -272,7 +320,7 @@ describe('BuildComparator', () => {
           return false;
         });
       };
-      expect(comparator.toMarkdown({ rowFilter })).toMatchInlineSnapshot(`
+      expect(comparator.toMarkdown({ artifactFilter })).toMatchInlineSnapshot(`
 "|          |  1234567 |  8901234 |                  Δ1 |
 | :------- | -------: | -------: | ------------------: |
 | All      | 0.13 KiB | 0.16 KiB |    0.03 KiB (20.7%) |
@@ -300,8 +348,8 @@ describe('BuildComparator', () => {
       // @ts-ignore
       const formatDelta = (cell: DeltaCell | TotalDeltaCell): string => cell.percents.gzip.toFixed(2);
       // @ts-ignore
-      const rowFilter = (row: Array<BodyCell>): boolean => row[0].text !== 'burritos';
-      expect(comparator.toMarkdown({ formatRevision, formatRevisionDelta, formatTotal, formatDelta, rowFilter }))
+      const artifactFilter = (row: ArtifactRow): boolean => row[0].text !== 'burritos';
+      expect(comparator.toMarkdown({ formatRevision, formatRevisionDelta, formatTotal, formatDelta, artifactFilter }))
         .toMatchInlineSnapshot(`
 "|         |  12 |  89 |    d1 |
 | :------ | --: | --: | ----: |
@@ -335,9 +383,9 @@ tacos,0.12 KiB,0.12 KiB,0 KiB (0.0%)"
 `);
     });
 
-    test('can filter rows', () => {
+    test('can filter artifacts', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
-      const rowFilter = (row): boolean => {
+      const artifactFilter = (row): boolean => {
         return row.some(cell => {
           if (cell.sizes && 'gzip' in cell.sizes) {
             return cell.sizes.gzip > 50;
@@ -345,7 +393,7 @@ tacos,0.12 KiB,0.12 KiB,0 KiB (0.0%)"
           return false;
         });
       };
-      expect(comparator.toCsv({ rowFilter })).toMatchInlineSnapshot(`
+      expect(comparator.toCsv({ artifactFilter })).toMatchInlineSnapshot(`
 ",1234567,8901234,Δ1
 All,0.13 KiB,0.16 KiB,0.03 KiB (20.7%)
 churros,0 KiB,0.12 KiB,0.12 KiB (100.0%)
@@ -371,8 +419,8 @@ tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
       // @ts-ignore
       const formatDelta = (cell: DeltaCell | TotalDeltaCell): string => cell.percents.gzip.toFixed(2);
       // @ts-ignore
-      const rowFilter = (row: Array<BodyCell>): boolean => row[0].text !== 'burritos';
-      expect(comparator.toCsv({ formatRevision, formatRevisionDelta, formatTotal, formatDelta, rowFilter }))
+      const artifactFilter = (row: ArtifactRow): boolean => row[0].text !== 'burritos';
+      expect(comparator.toCsv({ formatRevision, formatRevisionDelta, formatTotal, formatDelta, artifactFilter }))
         .toMatchInlineSnapshot(`
 ",12,89,d1
 All,579,592,0.21
diff --git a/src/server/src/api/__tests__/insert.test.ts b/src/server/src/api/__tests__/insert.test.ts
index f4955e0..3f0aa42 100644
--- a/src/server/src/api/__tests__/insert.test.ts
+++ b/src/server/src/api/__tests__/insert.test.ts
@@ -3,6 +3,7 @@
  */
 import bodyParser from 'body-parser';
 import Build from '@build-tracker/build';
+import Comparator from '@build-tracker/comparator';
 import express from 'express';
 import { insertBuild } from '../insert';
 import request from 'supertest';
@@ -11,12 +12,16 @@ const build = new Build({ revision: 'abc', parentRevision: 'def', timestamp: Dat
 const parentBuild = new Build({ revision: 'def', parentRevision: '123', timestamp: Date.now() }, []);
 
 describe('insert build handler', () => {
-  describe('onInsert', () => {
-    test('responds with ...something TODO', () => {
-      const getParent = jest.fn(() => Promise.resolve(parentBuild));
-      const handler = insertBuild(getParent);
-      const app = express();
-      app.use(bodyParser.json());
+  let getParent, app;
+  beforeEach(() => {
+    getParent = jest.fn(() => Promise.resolve(parentBuild));
+    app = express();
+    app.use(bodyParser.json());
+  });
+
+  describe('response', () => {
+    test('includes the comparator JSON', () => {
+      const handler = insertBuild(getParent, { artifacts: {} });
       app.post('/test', handler);
 
       return request(app)
@@ -25,16 +30,15 @@ describe('insert build handler', () => {
         .set('Content-Type', 'application/json')
         .set('Accept', 'application/json')
         .then(res => {
-          expect(res.body).toEqual({ foo: 'bar' });
+          expect(res.body).toMatchObject({ comparator: new Comparator({ builds: [build, parentBuild] }).toJSON() });
         });
     });
+  });
 
+  describe('onInserted', () => {
     test('called with a comparator of the inserted branch against its parentRevision', () => {
       const handleInsert = jest.fn(() => Promise.resolve());
-      const getParent = jest.fn(() => Promise.resolve(parentBuild));
-      const handler = insertBuild(getParent, handleInsert);
-      const app = express();
-      app.use(bodyParser.json());
+      const handler = insertBuild(getParent, { artifacts: {} }, handleInsert);
       app.post('/test', handler);
 
       return request(app)
diff --git a/src/server/src/api/__tests__/read.test.ts b/src/server/src/api/__tests__/read.test.ts
index 55cc320..9c43516 100644
--- a/src/server/src/api/__tests__/read.test.ts
+++ b/src/server/src/api/__tests__/read.test.ts
@@ -24,7 +24,7 @@ describe('read', () => {
   describe('queryByRevision', () => {
     test('queries by revision', () => {
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/build/1234567890')
@@ -37,7 +37,7 @@ describe('read', () => {
     test('throws 500 on failure', () => {
       queries.build.byRevision.mockImplementation(() => Promise.reject('tacos'));
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/build/1234567890')
@@ -50,7 +50,7 @@ describe('read', () => {
   describe('queryByRevisionRange', () => {
     test('queries by revision range', () => {
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/range/1234567..abcdef')
@@ -63,7 +63,7 @@ describe('read', () => {
     test('throws 500 on failure', () => {
       queries.builds.byRevisionRange.mockImplementation(() => Promise.reject('tacos'));
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/range/1234567..abcdef')
@@ -76,7 +76,7 @@ describe('read', () => {
   describe('queryByTimeRange', () => {
     test('queries by time range', () => {
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/time/1234567..2345678')
@@ -89,7 +89,7 @@ describe('read', () => {
     test('throws 500 on failure', () => {
       queries.builds.byTimeRange.mockImplementation(() => Promise.reject('tacos'));
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/time/1234567..2345678')
@@ -102,7 +102,7 @@ describe('read', () => {
   describe('queryByRevisions', () => {
     test('queries by revision range', () => {
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/list/1234567/abcdef/239587')
@@ -115,7 +115,7 @@ describe('read', () => {
     test('throws 500 on failure', () => {
       queries.builds.byRevisions.mockImplementation(() => Promise.reject('tacos'));
       const app = express();
-      app.use(middleware(express.Router(), queries, {}));
+      app.use(middleware(express.Router(), queries, {}, {}));
 
       return request(app)
         .get('/api/builds/list/1234567/abcdef/239587')