Repository: paularmstrong/build-tracker
Pull request #49 (2019-04-13 19:36:11)

Date picker for builds
# Problem

There's no way to pick date ranges of builds to display.

# Solution

* Create a calendar component using date-fns

# TODO

- [x] 🤓 Add & update tests (always try to _increase_ test coverage)
- [x] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation



Patch:
diff --git a/config/fixtures.js b/config/fixtures.js
index 4ed14fc..2e3ddaf 100644
--- a/config/fixtures.js
+++ b/config/fixtures.js
@@ -45,13 +45,13 @@ module.exports = {
         return Promise.resolve(
           Array.from(builds.values())
             .filter(build => build.meta.timestamp >= startTimestamp && build.meta.timestamp <= endTimestamp)
-            .sort((a, b) => b.meta.timestamp - a.meta.timestamp)
+            .sort((a, b) => a.meta.timestamp - b.meta.timestamp)
         );
       },
       recent: async (limit = 20) => {
         return Promise.resolve(
           Array.from(builds.values())
-            .sort((a, b) => b.meta.timestamp - a.meta.timestamp)
+            .sort((a, b) => a.meta.timestamp - b.meta.timestamp)
             .slice(-limit)
         );
       }
diff --git a/src/app/package.json b/src/app/package.json
index d7bd861..10a6e22 100644
--- a/src/app/package.json
+++ b/src/app/package.json
@@ -23,6 +23,7 @@
     "d3-selection": "^1.4.0",
     "d3-shape": "^1.3.4",
     "d3-transition": "^1.2.0",
+    "date-fns": "^1.30.1",
     "fbjs": "^1.0.0",
     "memoize-one": "^5.0.0",
     "react": "^16.8.2",
@@ -44,6 +45,7 @@
     "@types/d3-selection": "^1.4.1",
     "@types/d3-shape": "^1.3.1",
     "@types/d3-transition": "^1.1.4",
+    "@types/date-fns": "^2.6.0",
     "@types/memoize-one": "^4.1.0",
     "@types/react": "^16.8.3",
     "@types/react-dom": "^16.8.2",
diff --git a/src/app/src/components/DatePicker.tsx b/src/app/src/components/DatePicker.tsx
new file mode 100644
index 0000000..ee5b641
--- /dev/null
+++ b/src/app/src/components/DatePicker.tsx
@@ -0,0 +1,162 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Theme from '../theme';
+import addDays from 'date-fns/add_days';
+import addMonths from 'date-fns/add_months';
+import ArrowLeftIcon from '../icons/ArrowLeft';
+import ArrowRightIcon from '../icons/ArrowRight';
+import Button from './Button';
+import endOfMonth from 'date-fns/end_of_month';
+import formatDate from 'date-fns/format';
+import getDaysInMonth from 'date-fns/get_days_in_month';
+import isSameDay from 'date-fns/is_same_day';
+import isSameMonth from 'date-fns/is_same_month';
+import isToday from 'date-fns/is_today';
+import React from 'react';
+import RelativeModal from './RelativeModal';
+import startOfMonth from 'date-fns/start_of_month';
+import startOfToday from 'date-fns/start_of_today';
+import startOfWeek from 'date-fns/start_of_week';
+import subMonths from 'date-fns/sub_months';
+import { StyleSheet, Text, View } from 'react-native';
+
+interface Props extends React.ComponentProps<typeof RelativeModal> {
+  minDate?: Date;
+  maxDate?: Date;
+  onSelect?: (date: Date) => void;
+  selectedDate?: Date;
+}
+
+const DatePicker = (props: Props): React.ReactElement => {
+  const {
+    maxDate = new Date(8640000000000000),
+    minDate = new Date(-8640000000000000),
+    onDismiss,
+    onSelect,
+    relativeTo,
+    selectedDate = startOfToday()
+  } = props;
+
+  const [currentMonth, setCurrentMonth] = React.useState<Date>(startOfMonth(selectedDate));
+
+  const daysInMonth = getDaysInMonth(currentMonth);
+  const startDate = startOfWeek(startOfMonth(currentMonth), { weekStartsOn: 0 });
+
+  // @ts-ignore
+  const month = new Array(Math.ceil(daysInMonth / 7 + 2)).fill().reduce((memo, _week, weekIndex) => {
+    const firstDayOfWeek = addDays(startDate, weekIndex * 7);
+    if (weekIndex > 0 && !isSameMonth(firstDayOfWeek, currentMonth)) {
+      return memo;
+    }
+    memo.push(
+      // @ts-ignore
+      new Array(7).fill().map((_, dayIndex) => {
+        const date = addDays(firstDayOfWeek, dayIndex);
+        return { date, isSameMonth: isSameMonth(date, currentMonth) };
+      })
+    );
+    return memo;
+  }, []);
+
+  const handleNextMonth = React.useCallback(() => {
+    setCurrentMonth(currentMonth => addMonths(currentMonth, 1));
+  }, []);
+
+  const handlePreviousMonth = React.useCallback(() => {
+    setCurrentMonth(currentMonth => subMonths(currentMonth, 1));
+  }, []);
+
+  return (
+    <RelativeModal onDismiss={onDismiss} portalRootID="menuPortal" relativeTo={relativeTo}>
+      <View style={[styles.month]}>
+        <View style={styles.header}>
+          <View style={styles.day}>
+            <Button
+              disabled={endOfMonth(subMonths(currentMonth, 1)) < minDate}
+              icon={ArrowLeftIcon}
+              iconOnly
+              onPress={handlePreviousMonth}
+              title="Previous month"
+            />
+          </View>
+          <View style={styles.monthHeader}>
+            <Text style={styles.monthText}>{formatDate(currentMonth, 'MMMM')}</Text>
+          </View>
+          <View style={styles.day}>
+            <Button
+              disabled={startOfMonth(addMonths(currentMonth, 1)) > maxDate}
+              icon={ArrowRightIcon}
+              iconOnly
+              onPress={handleNextMonth}
+              title="Next month"
+            />
+          </View>
+        </View>
+        <View style={styles.week}>
+          {month[0].map((day, index) => (
+            <View key={index} style={styles.day}>
+              <Text style={styles.text}>{formatDate(day.date, 'dd')}</Text>
+            </View>
+          ))}
+        </View>
+        {month.map((week, index) => (
+          <View key={index} style={styles.week}>
+            {week.map((day, index) => {
+              const inRange = day.date <= maxDate && day.date >= minDate;
+              const isSelected = isSameDay(day.date, selectedDate);
+              const today = isToday(day.date);
+              return (
+                <View key={index} style={styles.day}>
+                  <Button
+                    accessibilityLabel={formatDate(day.date, 'YYYY-MM-DD')}
+                    color={today ? 'secondary' : 'primary'}
+                    disabled={!inRange}
+                    onPress={() => {
+                      onSelect(day.date);
+                    }}
+                    title={formatDate(day.date, 'D')}
+                    type={isSelected ? 'unelevated' : 'text'}
+                  />
+                </View>
+              );
+            })}
+          </View>
+        ))}
+      </View>
+    </RelativeModal>
+  );
+};
+
+const styles = StyleSheet.create({
+  root: {},
+  month: {
+    padding: Theme.Spacing.Xsmall
+  },
+  header: {
+    flexDirection: 'row'
+  },
+  day: {
+    flexGrow: 1,
+    flexBasis: `${(1 / 7) * 100}%`
+  },
+  hovered: {},
+  monthHeader: {
+    flexGrow: 1,
+    flexShrink: 0,
+    flexBasis: `${(5 / 7) * 100}%`,
+    justifyContent: 'center'
+  },
+  monthText: {
+    textAlign: 'center',
+    fontWeight: Theme.FontWeight.Bold
+  },
+  week: {
+    flexDirection: 'row'
+  },
+  text: {
+    textAlign: 'center'
+  }
+});
+
+export default DatePicker;
diff --git a/src/app/src/components/DateTextField.tsx b/src/app/src/components/DateTextField.tsx
new file mode 100644
index 0000000..c2e1f3a
--- /dev/null
+++ b/src/app/src/components/DateTextField.tsx
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import formatDate from 'date-fns/format';
+import isValid from 'date-fns/is_valid';
+import React from 'react';
+import TextField from '../components/TextField';
+import { StyleProp, View, ViewStyle } from 'react-native';
+
+const DatePicker = React.lazy(() => import(/* webpackChunkName: "DatePicker" */ '../components/DatePicker'));
+
+interface Props {
+  initialValue?: Date;
+  label: string;
+  maxDate?: Date;
+  minDate?: Date;
+  onSet?: (date: Date) => void;
+  style?: StyleProp<ViewStyle>;
+}
+
+const format = 'YYYY-MM-DD';
+
+const DateTextField = (props: Props): React.ReactElement => {
+  const { initialValue, label, maxDate, minDate, onSet, style } = props;
+
+  const [datePickerVisible, setDatePickerVisible] = React.useState(false);
+  const [value, setValue] = React.useState<Date>(initialValue);
+  const [stringValue, setStringValue] = React.useState<string>(
+    initialValue && isValid(initialValue) ? formatDate(initialValue, format) : ''
+  );
+  const startDateRef = React.useRef<View>(null);
+
+  const toggleDatePicker = React.useCallback(() => {
+    setDatePickerVisible(visible => !visible);
+  }, []);
+
+  const handleSelect = React.useCallback(
+    (date: Date) => {
+      setValue(date);
+      setStringValue(formatDate(date, format));
+      onSet && onSet(date);
+      setDatePickerVisible(false);
+    },
+    [onSet]
+  );
+
+  const handleChangeText = React.useCallback(
+    (value: string) => {
+      setStringValue(value);
+      const date = new Date(value);
+      if (isValid(date)) {
+        setValue(date);
+        onSet && onSet(date);
+      }
+    },
+    [onSet]
+  );
+
+  const handleBlur = React.useCallback((): void => {
+    setDatePickerVisible(false);
+  }, []);
+
+  return (
+    <View ref={startDateRef} style={style}>
+      <TextField
+        label={label}
+        onBlur={handleBlur}
+        onChangeText={handleChangeText}
+        onFocus={toggleDatePicker}
+        value={stringValue}
+      />
+      {datePickerVisible ? (
+        <React.Suspense fallback={null}>
+          <DatePicker
+            maxDate={maxDate}
+            minDate={minDate}
+            onDismiss={toggleDatePicker}
+            onSelect={handleSelect}
+            relativeTo={startDateRef}
+            selectedDate={value}
+          />
+        </React.Suspense>
+      ) : null}
+    </View>
+  );
+};
+
+export default DateTextField;
diff --git a/src/app/src/components/EmptyState.tsx b/src/app/src/components/EmptyState.tsx
new file mode 100644
index 0000000..d48ce7c
--- /dev/null
+++ b/src/app/src/components/EmptyState.tsx
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+interface Props {
+  message: string;
+}
+
+const EmptyState = (props: Props): React.ReactElement => {
+  const { message } = props;
+  return (
+    <View style={styles.root}>
+      <Text>{message}</Text>
+    </View>
+  );
+};
+
+const styles = StyleSheet.create({
+  root: {
+    flexGrow: 1,
+    width: '100%',
+    height: '100%',
+    justifyContent: 'center',
+    alignItems: 'center'
+  }
+});
+
+export default EmptyState;
diff --git a/src/app/src/components/Graph/index.tsx b/src/app/src/components/Graph/index.tsx
index 67113d8..43b9347 100644
--- a/src/app/src/components/Graph/index.tsx
+++ b/src/app/src/components/Graph/index.tsx
@@ -24,9 +24,12 @@ export class SVG extends React.Component<{ height: number; width: number }> {
   }
 }
 
+interface Props {
+  comparator: State['comparator'];
+}
+
 interface MappedState {
   activeArtifacts: State['activeArtifacts'];
-  comparator: State['comparator'];
   colorScale: ScaleSequential<string>;
   hoveredArtifacts: State['hoveredArtifacts'];
   selectedRevisions: State['comparedRevisions'];
@@ -35,17 +38,15 @@ interface MappedState {
 
 const mapState = (state: State): MappedState => ({
   activeArtifacts: state.activeArtifacts,
-  comparator: state.comparator,
   colorScale: ColorScales[state.colorScale].domain([0, state.comparator.artifactNames.length]),
   hoveredArtifacts: state.hoveredArtifacts,
   selectedRevisions: state.comparedRevisions,
   sizeKey: state.sizeKey
 });
 
-const Graph = (): React.ReactElement => {
-  const { activeArtifacts, comparator, colorScale, hoveredArtifacts, selectedRevisions, sizeKey } = useMappedState(
-    mapState
-  );
+const Graph = (props: Props): React.ReactElement => {
+  const { comparator } = props;
+  const { activeArtifacts, colorScale, hoveredArtifacts, selectedRevisions, sizeKey } = useMappedState(mapState);
   const dispatch = useDispatch();
 
   const [{ width, height }, setDimensions] = React.useState({ width: 0, height: 0 });
diff --git a/src/app/src/components/RelativeModal.tsx.rej b/src/app/src/components/RelativeModal.tsx.rej
new file mode 100644
index 0000000..e36d2f9
--- /dev/null
+++ b/src/app/src/components/RelativeModal.tsx.rej
@@ -0,0 +1,4 @@
+diff a/src/app/src/components/RelativeModal.tsx b/src/app/src/components/RelativeModal.tsx	(rejected hunks)
+@@ -25 +25 @@ const Menu = (props: Props): React.ReactElement => {
+-  }, []);
++  }, [onDismiss]);
diff --git a/src/app/src/components/TextField.tsx b/src/app/src/components/TextField.tsx
index f483350..5677fa4 100644
--- a/src/app/src/components/TextField.tsx
+++ b/src/app/src/components/TextField.tsx
@@ -23,6 +23,7 @@ const TextField = (props: Props, ref?: React.RefObject<TextInput>): React.ReactE
     onFocus,
     style,
     trailingIcon: TrailingIcon,
+    value: propValue,
     ...textInputProps
   } = props;
   const [isFocused, setFocused] = React.useState(false);
@@ -52,6 +53,10 @@ const TextField = (props: Props, ref?: React.RefObject<TextInput>): React.ReactE
     [onChangeText, setValue]
   );
 
+  if (propValue !== value) {
+    setValue(propValue);
+  }
+
   const labelMoved = isFocused || value !== '';
 
   return (
diff --git a/src/app/src/icons/ArrowLeft.tsx b/src/app/src/icons/ArrowLeft.tsx
new file mode 100644
index 0000000..c7a2269
--- /dev/null
+++ b/src/app/src/icons/ArrowLeft.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 ArrowLeft = (props: Props): React.ReactElement<Props> =>
+  createElement(
+    'svg',
+    {
+      ...props,
+      style: [styles.root, props.style],
+      viewBox: '0 0 24 24'
+    },
+    <g>
+      <path d="M14 7l-5 5 5 5V7z" />
+      <path fill="none" d="M24 0v24H0V0h24z" />
+    </g>
+  );
+
+ArrowLeft.metadata = { height: 24, width: 24 };
+
+export default ArrowLeft;
diff --git a/src/app/src/icons/ArrowRight.tsx b/src/app/src/icons/ArrowRight.tsx
new file mode 100644
index 0000000..d737d07
--- /dev/null
+++ b/src/app/src/icons/ArrowRight.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 ArrowRight = (props: Props): React.ReactElement<Props> =>
+  createElement(
+    'svg',
+    {
+      ...props,
+      style: [styles.root, props.style],
+      viewBox: '0 0 24 24'
+    },
+    <g>
+      <path d="M10 17l5-5-5-5v10z" />
+      <path fill="none" d="M0 24V0h24v24H0z" />
+    </g>
+  );
+
+ArrowRight.metadata = { height: 24, width: 24 };
+
+export default ArrowRight;
diff --git a/src/app/src/screens/Main.tsx b/src/app/src/screens/Main.tsx
index 9f1c93f..3a386e1 100644
--- a/src/app/src/screens/Main.tsx
+++ b/src/app/src/screens/Main.tsx
@@ -7,43 +7,76 @@ import Build from '@build-tracker/build';
 import Drawer from '../components/Drawer';
 import DrawerView from '../views/Drawer';
 import { fetch } from 'cross-fetch';
-import Graph from '../components/Graph';
+import Graph from '../views/Graph';
 import React from 'react';
 import { setBuilds } from '../store/actions';
 import Snacks from '../views/Snacks';
 import { State } from '../store/types';
-import { StyleSheet, View } from 'react-native';
+import { ActivityIndicator, StyleSheet, View } from 'react-native';
 import { useDispatch, useMappedState } from 'redux-react-hook';
 
 const Comparison = React.lazy(() => import(/* webpackChunkName: "Comparison" */ '../views/Comparison'));
 
 interface MappedState {
   buildsCount: number;
+  dateRange: { start: Date; end: Date };
   showComparisonTable: boolean;
   url: string;
 }
 
 const mapState = (state: State): MappedState => ({
   buildsCount: state.builds.length,
+  dateRange: state.dateRange,
   showComparisonTable: !!state.comparedRevisions.length,
   url: state.url
 });
 
+const dateToSeconds = (date: Date): number => Math.round(date.valueOf() / 1000);
+
+enum FetchState {
+  NONE,
+  FETCHING,
+  FETCHED,
+  ERROR
+}
+
 const Main = (): React.ReactElement => {
   const drawerRef: React.RefObject<Drawer> = React.useRef(null);
 
-  const { buildsCount, showComparisonTable, url } = useMappedState(mapState);
+  const { buildsCount, dateRange, showComparisonTable, url } = useMappedState(mapState);
+  const [fetchState, setFetchState] = React.useState<FetchState>(FetchState.NONE);
   const dispatch = useDispatch();
 
   React.useEffect(() => {
+    setFetchState(FetchState.FETCHING);
+    if (dateRange) {
+      fetch(`${url}/api/builds/time/${dateToSeconds(dateRange.start)}...${dateToSeconds(dateRange.end)}`)
+        .then(response => response.json())
+        .then(builds => {
+          dispatch(setBuilds(builds.map(buildStruct => new Build(buildStruct.meta, buildStruct.artifacts))));
+        })
+        .then(() => {
+          setFetchState(FetchState.FETCHED);
+        })
+        .catch(() => {
+          setFetchState(FetchState.ERROR);
+        });
+      return;
+    }
     if (buildsCount === 0) {
       fetch(`${url}/api/builds`)
         .then(response => response.json())
         .then(builds => {
           dispatch(setBuilds(builds.map(buildStruct => new Build(buildStruct.meta, buildStruct.artifacts))));
+        })
+        .then(() => {
+          setFetchState(FetchState.FETCHED);
+        })
+        .catch(() => {
+          setFetchState(FetchState.ERROR);
         });
     }
-  }, [buildsCount, dispatch, url]);
+  }, [buildsCount, dateRange, dispatch, url]);
 
   return (
     <View style={styles.layout}>
@@ -55,7 +88,13 @@ const Main = (): React.ReactElement => {
       >
         <View style={[styles.column, styles.chart]}>
           <AppBarView drawerRef={drawerRef} />
-          <Graph />
+          {fetchState == FetchState.FETCHED ? (
+            <Graph />
+          ) : fetchState == FetchState.FETCHING ? (
+            <View style={styles.loading}>
+              <ActivityIndicator accessibilityLabel="Loading builds…" color={Theme.Color.Secondary30} size={'large'} />
+            </View>
+          ) : null}
         </View>
         <Snacks />
         {showComparisonTable ? (
@@ -86,6 +125,11 @@ const styles = StyleSheet.create({
     height: '100vh',
     alignItems: 'flex-start'
   },
+  loading: {
+    flexGrow: 1,
+    alignSelf: 'center',
+    justifyContent: 'center'
+  },
   chart: {
     flexDirection: 'column'
   },
diff --git a/src/app/src/store/actions.ts b/src/app/src/store/actions.ts
index c0803a4..0bc0341 100644
--- a/src/app/src/store/actions.ts
+++ b/src/app/src/store/actions.ts
@@ -12,6 +12,7 @@ import {
   SetArtifactsActiveAction,
   SetBuildsAction,
   SetColorScaleAction,
+  SetDateRange,
   SetDisabledArtifactsVisible,
   SetFocusedRevision,
   SetHoveredArtifacts,
@@ -72,3 +73,8 @@ export const setHoveredArtifacts = (artifactNames: Array<string>): SetHoveredArt
 });
 
 export const setSizeKey = (key: string): SetSizeKey => ({ type: 'SET_SIZE_KEY', payload: key });
+
+export const setDateRange = (start: Date, end: Date): SetDateRange => ({
+  type: 'SET_DATE_RANGE',
+  payload: { start, end }
+});
diff --git a/src/app/src/store/reducer.ts b/src/app/src/store/reducer.ts
index 2449232..e29a765 100644
--- a/src/app/src/store/reducer.ts
+++ b/src/app/src/store/reducer.ts
@@ -88,6 +88,9 @@ export default function reducer(state: State, action: Actions): State {
       }
       return { ...state, hoveredArtifacts: action.payload };
 
+    case 'SET_DATE_RANGE':
+      return { ...state, comparedRevisions: [], dateRange: action.payload };
+
     default:
       return state;
   }
diff --git a/src/app/src/store/types.ts b/src/app/src/store/types.ts
index 4cc51a6..182e1f2 100644
--- a/src/app/src/store/types.ts
+++ b/src/app/src/store/types.ts
@@ -6,6 +6,11 @@ import Build from '@build-tracker/build';
 import ColorScale from '../modules/ColorScale';
 import Comparator from '@build-tracker/comparator';
 
+interface DateRange {
+  start: Date;
+  end: Date;
+}
+
 export interface State {
   activeArtifacts: { [key: string]: boolean };
   activeComparator: Comparator;
@@ -14,6 +19,7 @@ export interface State {
   colorScale: keyof typeof ColorScale;
   comparator: Comparator;
   comparedRevisions: Array<string>;
+  dateRange?: DateRange;
   disabledArtifactsVisible: boolean;
   focusedRevision?: string;
   hoveredArtifacts: Array<string>;
@@ -39,6 +45,7 @@ export type SetSizeKey = Action<'SET_SIZE_KEY', string>;
 export type AddSnack = Action<'ADD_SNACK', string>;
 export type RemoveSnack = Action<'REMOVE_SNACK', string>;
 export type SetHoveredArtifacts = Action<'HOVER_ARTIFACTS', Array<string>>;
+export type SetDateRange = Action<'SET_DATE_RANGE', DateRange>;
 
 export type Actions =
   | SetArtifactsActiveAction
@@ -52,4 +59,5 @@ export type Actions =
   | SetSizeKey
   | AddSnack
   | RemoveSnack
-  | SetHoveredArtifacts;
+  | SetHoveredArtifacts
+  | SetDateRange;
diff --git a/src/app/src/views/Drawer.tsx b/src/app/src/views/Drawer.tsx
index f9b2cf1..8471f04 100644
--- a/src/app/src/views/Drawer.tsx
+++ b/src/app/src/views/Drawer.tsx
@@ -2,34 +2,47 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../theme';
+import Button from '../components/Button';
 import ColorScalePicker from '../components/ColorScalePicker';
+import DateTextField from '../components/DateTextField';
 import Divider from '../components/Divider';
 import Drawer from '../components/Drawer';
 import DrawerLink from '../components/DrawerLink';
+import endOfDay from 'date-fns/end_of_day';
 import HeartIcon from '../icons/Heart';
 import OpenInExternalIcon from '../icons/OpenInExternal';
 import React from 'react';
-import { setDisabledArtifactsVisible } from '../store/actions';
 import SizeKeyPicker from '../components/SizeKeyPicker';
+import startOfDay from 'date-fns/start_of_day';
 import { State } from '../store/types';
 import Subtitle from '../components/Subtitle';
+import { setDateRange, setDisabledArtifactsVisible } from '../store/actions';
 import { StyleSheet, Switch, Text, View } from 'react-native';
 import { useDispatch, useMappedState } from 'redux-react-hook';
 
 interface MappedState {
   comparator: State['comparator'];
   disabledArtifactsVisible: State['disabledArtifactsVisible'];
+  storeEnd: Date;
+  storeStart: Date;
 }
 
+const today = new Date();
+
 const mapState = (state: State): MappedState => ({
   comparator: state.comparator,
-  disabledArtifactsVisible: state.disabledArtifactsVisible
+  disabledArtifactsVisible: state.disabledArtifactsVisible,
+  storeEnd: state.dateRange ? state.dateRange.end : null,
+  storeStart: state.dateRange ? state.dateRange.start : null
 });
 
 const DrawerView = (_props: {}, ref: React.RefObject<Drawer>): React.ReactElement => {
-  const { comparator, disabledArtifactsVisible } = useMappedState(mapState);
+  const { comparator, disabledArtifactsVisible, storeEnd, storeStart } = useMappedState(mapState);
   const dispatch = useDispatch();
 
+  const [startDate, setStartDate] = React.useState<Date>(storeStart);
+  const [endDate, setEndDate] = React.useState<Date>(storeEnd);
+
   const handleToggleDisabled = React.useCallback(
     (showDisabled: boolean): void => {
       dispatch(setDisabledArtifactsVisible(showDisabled));
@@ -37,15 +50,33 @@ const DrawerView = (_props: {}, ref: React.RefObject<Drawer>): React.ReactElemen
     [dispatch]
   );
 
+  const handleSetDateRange = React.useCallback((): void => {
+    dispatch(setDateRange(startOfDay(startDate), endOfDay(endDate)));
+  }, [dispatch, startDate, endDate]);
+
   return (
     <Drawer hidden ref={ref}>
       <View style={styles.header}>
         <Text style={styles.title}>Build Tracker</Text>
       </View>
+
+      <Divider />
+
+      <Subtitle title="Date range" />
+      <DateTextField maxDate={endDate || today} label="Start date" onSet={setStartDate} style={styles.date} />
+      <DateTextField minDate={startDate} maxDate={today} label="End date" onSet={setEndDate} style={styles.date} />
+      <View style={styles.date}>
+        <Button disabled={!startDate || !endDate} onPress={handleSetDateRange} title="Get range" type="unelevated" />
+      </View>
+
       <Divider />
+
       <Subtitle title="Compare artifacts by" />
+
       <SizeKeyPicker keys={comparator.sizeKeys} />
+
       <Divider />
+
       <View style={styles.switchRoot}>
         {
           // @ts-ignore
@@ -59,7 +90,9 @@ const DrawerView = (_props: {}, ref: React.RefObject<Drawer>): React.ReactElemen
         }
         <Text>Show disabled artifacts</Text>
       </View>
+
       <Divider />
+
       <Subtitle title="Color scale" />
       <ColorScalePicker />
       <Divider />
@@ -99,6 +132,9 @@ const styles = StyleSheet.create({
   switch: {
     marginEnd: Theme.Spacing.Small
   },
+  date: {
+    marginBottom: Theme.Spacing.Small
+  },
   attribution: {
     paddingVertical: Theme.Spacing.Large,
     flexDirection: 'row'
diff --git a/src/app/src/views/Graph.tsx b/src/app/src/views/Graph.tsx
new file mode 100644
index 0000000..da0944c
--- /dev/null
+++ b/src/app/src/views/Graph.tsx
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import EmptyState from '../components/EmptyState';
+import Graph from '../components/Graph';
+import React from 'react';
+import { State } from '../store/types';
+import { useMappedState } from 'redux-react-hook';
+
+interface MappedState {
+  comparator: State['comparator'];
+}
+
+const mapState = (state: State): MappedState => ({
+  comparator: state.comparator
+});
+
+const GraphView = (): React.ReactElement => {
+  const { comparator } = useMappedState(mapState);
+
+  if (comparator.builds.length) {
+    return <Graph comparator={comparator} />;
+  }
+
+  return <EmptyState message="No builds found. Choose another range from the menu." />;
+};
+
+export default GraphView;
diff --git a/src/build/src/index.ts b/src/build/src/index.ts
index b18b1b1..3a9b8ec 100644
--- a/src/build/src/index.ts
+++ b/src/build/src/index.ts
@@ -71,7 +71,7 @@ export default class Build<M extends BuildMeta = BuildMeta, A extends ArtifactSi
   }
 
   public get timestamp(): Date {
-    return new Date(this._meta.timestamp);
+    return new Date(parseInt(`${this._meta.timestamp}000`, 10));
   }
 
   public getMetaValue(key: keyof Omit<M, 'timestamp'>): string {
diff --git a/src/comparator/src/BuildDelta.ts b/src/comparator/src/BuildDelta.ts
index 7a0a7d3..a0c3081 100644
--- a/src/comparator/src/BuildDelta.ts
+++ b/src/comparator/src/BuildDelta.ts
@@ -65,6 +65,14 @@ export default class BuildDelta<M extends BuildMeta = BuildMeta, A extends Artif
     if (!this._artifactDeltas) {
       this.artifactDeltas;
     }
+    if (!this._artifactDeltas.has(name)) {
+      // @ts-ignore
+      const noSize: A = this.artifactSizes.reduce((memo, key) => {
+        memo[key] = 0;
+        return memo;
+      }, {});
+      return new ArtifactDelta<A>(name, [], noSize, noSize, false);
+    }
     return this._artifactDeltas.get(name);
   }
 
diff --git a/src/comparator/src/artifact-math.ts b/src/comparator/src/artifact-math.ts
index 621a62f..2be3d86 100644
--- a/src/comparator/src/artifact-math.ts
+++ b/src/comparator/src/artifact-math.ts
@@ -31,7 +31,7 @@ export const percentDelta = (key: string, baseSizes?: ArtifactSizes, prevSizes?:
   const base = prevSizes[key];
   const changed = baseSizes[key];
   if (base === 0) {
-    return 1;
+    return changed === 0 ? 0 : 1;
   } else if (changed > base) {
     const delta = changed - base;
     return delta / base;
diff --git a/src/comparator/src/index.ts b/src/comparator/src/index.ts
index 272f688..ee8f2cd 100644
--- a/src/comparator/src/index.ts
+++ b/src/comparator/src/index.ts
@@ -130,6 +130,7 @@ export default class BuildComparator {
   private _artifactNames: Array<string>;
   private _sizeKeys: Array<string>;
   private _buildDeltas: Array<Array<BuildDelta>>;
+  private _emptySizes: Readonly<{ [key: string]: number }>;
 
   private _matrixHeader: ComparisonMatrix['header'];
   private _matrixGroups: ComparisonMatrix['groups'];
@@ -140,6 +141,12 @@ export default class BuildComparator {
     this._artifactFilters = artifactFilters || [];
     this._artifactBudgets = artifactBudgets || emptyObject;
     this._groups = [{ name: 'All', artifactNames: this.artifactNames }, ...groups].filter(Boolean);
+    this._emptySizes = Object.freeze(
+      this.sizeKeys.reduce((memo, key) => {
+        memo[key] = 0;
+        return memo;
+      }, {})
+    );
   }
 
   public get artifactNames(): Array<string> {
@@ -164,7 +171,7 @@ export default class BuildComparator {
   }
 
   public get sizeKeys(): Array<string> {
-    if (this.builds.length === 0) {
+    if (this.builds.length === 0 || this.artifactNames.length === 0) {
       return [];
     }
 
@@ -179,8 +186,8 @@ export default class BuildComparator {
         });
       });
 
-      if (allSizeKeys.size !== this._sizeKeys.length) {
-        throw new Error();
+      if (allSizeKeys.size !== this._sizeKeys.length || !this._sizeKeys.every(key => allSizeKeys.has(key))) {
+        throw new Error('builds provided do not have same size keys for artifacts');
       }
     }
     return this._sizeKeys;
@@ -249,7 +256,7 @@ export default class BuildComparator {
         const artifact = this.builds[i].getArtifact(artifactName);
         return [
           {
-            sizes: artifact ? artifact.sizes : emptyObject,
+            sizes: artifact ? artifact.sizes : this._emptySizes,
             name: artifactName,
             type: CellType.TOTAL
           },
diff --git a/src/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json b/src/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json
index 565c7ae..958053c 100755
--- a/src/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json
+++ b/src/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "01141f29743fb2bdd7e176cf919fc964025cea5a",
-    "timestamp": 1501949493000,
+    "timestamp": 1501949493,
     "parentRevision": "fa752a9ce64a440f16e2c3670aa97a28be25c71f"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/1327ec715a0a2dd204eb7bede5f86bcf9c78f324.json b/src/fixtures/builds/1327ec715a0a2dd204eb7bede5f86bcf9c78f324.json
index e6c3a63..98683e7 100755
--- a/src/fixtures/builds/1327ec715a0a2dd204eb7bede5f86bcf9c78f324.json
+++ b/src/fixtures/builds/1327ec715a0a2dd204eb7bede5f86bcf9c78f324.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "1327ec715a0a2dd204eb7bede5f86bcf9c78f324",
-    "timestamp": 1501951671000,
+    "timestamp": 1501951671,
     "parentRevision": "c74d7997da43e8b51b66b09a75cb24f2b49fe645"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/188aa19da077a0db3972b52f0f1b580fafe75f49.json b/src/fixtures/builds/188aa19da077a0db3972b52f0f1b580fafe75f49.json
index 02a0b5d..efd51d7 100755
--- a/src/fixtures/builds/188aa19da077a0db3972b52f0f1b580fafe75f49.json
+++ b/src/fixtures/builds/188aa19da077a0db3972b52f0f1b580fafe75f49.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "188aa19da077a0db3972b52f0f1b580fafe75f49",
-    "timestamp": 1501958399000,
+    "timestamp": 1501958399,
     "parentRevision": "d48a6bde107f8663818fd5d2460ee059365cc5e1"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/19868a0432f039d45783bca1845cede313fbfbe1.json b/src/fixtures/builds/19868a0432f039d45783bca1845cede313fbfbe1.json
index ea89183..c6fb1cd 100755
--- a/src/fixtures/builds/19868a0432f039d45783bca1845cede313fbfbe1.json
+++ b/src/fixtures/builds/19868a0432f039d45783bca1845cede313fbfbe1.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "19868a0432f039d45783bca1845cede313fbfbe1",
-    "timestamp": 1501954298000,
+    "timestamp": 1501954298,
     "parentRevision": "243024909db66ac3c3e48d2ffe4015f049609834"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/1bf811ec249c2b13a314e127312b2d760f6658e2.json b/src/fixtures/builds/1bf811ec249c2b13a314e127312b2d760f6658e2.json
index 0d520dc..65e197d 100755
--- a/src/fixtures/builds/1bf811ec249c2b13a314e127312b2d760f6658e2.json
+++ b/src/fixtures/builds/1bf811ec249c2b13a314e127312b2d760f6658e2.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "1bf811ec249c2b13a314e127312b2d760f6658e2",
-    "timestamp": 1501964034000,
+    "timestamp": 1501964034,
     "parentRevision": "bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9.json b/src/fixtures/builds/1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9.json
index f62a867..a341c5a 100755
--- a/src/fixtures/builds/1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9.json
+++ b/src/fixtures/builds/1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9",
-    "timestamp": 1501959773000,
+    "timestamp": 1501959773,
     "parentRevision": "71a383fdf7ae287aa1f18ebe7e270b0520da2536"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/20fd519231b53d27e4ce7c3914f815e0b02684b4.json b/src/fixtures/builds/20fd519231b53d27e4ce7c3914f815e0b02684b4.json
index a2ba85b..5c03a3f 100755
--- a/src/fixtures/builds/20fd519231b53d27e4ce7c3914f815e0b02684b4.json
+++ b/src/fixtures/builds/20fd519231b53d27e4ce7c3914f815e0b02684b4.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "20fd519231b53d27e4ce7c3914f815e0b02684b4",
-    "timestamp": 1501957331000,
+    "timestamp": 1501957331,
     "parentRevision": "99649d52f3bf158766c90120b7c3a60d34d6f76c"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json b/src/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json
index 4faf4a3..61e42c4 100755
--- a/src/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json
+++ b/src/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04",
-    "timestamp": 1501955780000,
+    "timestamp": 1501955780,
     "parentRevision": "30af629d1d4c9f2f199cec5f572a019d4198004c"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json b/src/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json
index 0bef266..4a4d6f8 100755
--- a/src/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json
+++ b/src/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "243024909db66ac3c3e48d2ffe4015f049609834",
-    "timestamp": 1501954535000,
+    "timestamp": 1501954535,
     "parentRevision": "22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/298dfe16af18f420dfbb192d2f38b1ac6022abb8.json b/src/fixtures/builds/298dfe16af18f420dfbb192d2f38b1ac6022abb8.json
index c7d8cb0..2a44a8a 100755
--- a/src/fixtures/builds/298dfe16af18f420dfbb192d2f38b1ac6022abb8.json
+++ b/src/fixtures/builds/298dfe16af18f420dfbb192d2f38b1ac6022abb8.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "foobar",
     "revision": "298dfe16af18f420dfbb192d2f38b1ac6022abb8",
-    "timestamp": 1501948672000,
+    "timestamp": 1501948672,
     "parentRevision": "f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/30af629d1d4c9f2f199cec5f572a019d4198004c.json b/src/fixtures/builds/30af629d1d4c9f2f199cec5f572a019d4198004c.json
index 49234c7..4608371 100755
--- a/src/fixtures/builds/30af629d1d4c9f2f199cec5f572a019d4198004c.json
+++ b/src/fixtures/builds/30af629d1d4c9f2f199cec5f572a019d4198004c.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "30af629d1d4c9f2f199cec5f572a019d4198004c",
-    "timestamp": 1501956579000,
+    "timestamp": 1501956579,
     "parentRevision": "20fd519231b53d27e4ce7c3914f815e0b02684b4"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/3d7d22d51db1369426c4ae1a300180c325e0b61d.json b/src/fixtures/builds/3d7d22d51db1369426c4ae1a300180c325e0b61d.json
index 2d2b3e6..2b02639 100755
--- a/src/fixtures/builds/3d7d22d51db1369426c4ae1a300180c325e0b61d.json
+++ b/src/fixtures/builds/3d7d22d51db1369426c4ae1a300180c325e0b61d.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "3d7d22d51db1369426c4ae1a300180c325e0b61d",
-    "timestamp": 1501957740000,
+    "timestamp": 1501957740,
     "parentRevision": "d167fec4ee6a0bec23a06ce6085a93af3379f287"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/4a8882483a664401a602f64a882d0ed7fb1763cb.json b/src/fixtures/builds/4a8882483a664401a602f64a882d0ed7fb1763cb.json
index 20a7aa6..9cc2207 100755
--- a/src/fixtures/builds/4a8882483a664401a602f64a882d0ed7fb1763cb.json
+++ b/src/fixtures/builds/4a8882483a664401a602f64a882d0ed7fb1763cb.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "4a8882483a664401a602f64a882d0ed7fb1763cb",
-    "timestamp": 1501953597000,
+    "timestamp": 1501953597,
     "parentRevision": "19868a0432f039d45783bca1845cede313fbfbe1"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/4c9d2a8fc24d37c967cf0e596f293d3168b7696e.json b/src/fixtures/builds/4c9d2a8fc24d37c967cf0e596f293d3168b7696e.json
index 0fdfbea..b2c0fcd 100755
--- a/src/fixtures/builds/4c9d2a8fc24d37c967cf0e596f293d3168b7696e.json
+++ b/src/fixtures/builds/4c9d2a8fc24d37c967cf0e596f293d3168b7696e.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "foobar",
     "revision": "4c9d2a8fc24d37c967cf0e596f293d3168b7696e",
-    "timestamp": 1501949484000,
+    "timestamp": 1501949484,
     "parentRevision": "01141f29743fb2bdd7e176cf919fc964025cea5a"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/4d06472b21aef812ffb2ce70dbd41a08529fbfe6.json b/src/fixtures/builds/4d06472b21aef812ffb2ce70dbd41a08529fbfe6.json
index e1b23a6..4a2f328 100755
--- a/src/fixtures/builds/4d06472b21aef812ffb2ce70dbd41a08529fbfe6.json
+++ b/src/fixtures/builds/4d06472b21aef812ffb2ce70dbd41a08529fbfe6.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "4d06472b21aef812ffb2ce70dbd41a08529fbfe6",
-    "timestamp": 1501958071000,
+    "timestamp": 1501958071,
     "parentRevision": "188aa19da077a0db3972b52f0f1b580fafe75f49"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/5113828d6b0e628a78fa383a53933c413b6bdbef.json b/src/fixtures/builds/5113828d6b0e628a78fa383a53933c413b6bdbef.json
index 3f3d484..4c21de4 100755
--- a/src/fixtures/builds/5113828d6b0e628a78fa383a53933c413b6bdbef.json
+++ b/src/fixtures/builds/5113828d6b0e628a78fa383a53933c413b6bdbef.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "5113828d6b0e628a78fa383a53933c413b6bdbef",
-    "timestamp": 1501964802000,
+    "timestamp": 1501964802,
     "parentRevision": "000000"
   },
   "artifacts": [
@@ -423,4 +423,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/6666692feabaa56483f2d21eaec994bf75ac2583.json b/src/fixtures/builds/6666692feabaa56483f2d21eaec994bf75ac2583.json
index 241d612..3bc481e 100755
--- a/src/fixtures/builds/6666692feabaa56483f2d21eaec994bf75ac2583.json
+++ b/src/fixtures/builds/6666692feabaa56483f2d21eaec994bf75ac2583.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "6666692feabaa56483f2d21eaec994bf75ac2583",
-    "timestamp": 1501959296000,
+    "timestamp": 1501959296,
     "parentRevision": "1d088ea7d0aa3a93fa0d5c0497a3fc96b33ce1c9"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/703baac11f1be4b2d02ddb89de6789c7605183b0.json b/src/fixtures/builds/703baac11f1be4b2d02ddb89de6789c7605183b0.json
index 8aeb067..95477ee 100755
--- a/src/fixtures/builds/703baac11f1be4b2d02ddb89de6789c7605183b0.json
+++ b/src/fixtures/builds/703baac11f1be4b2d02ddb89de6789c7605183b0.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "703baac11f1be4b2d02ddb89de6789c7605183b0",
-    "timestamp": 1501964636000,
+    "timestamp": 1501964636,
     "parentRevision": "7f68d84c59b4604ecd88943eae4a88a41b137ed6"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/71a383fdf7ae287aa1f18ebe7e270b0520da2536.json b/src/fixtures/builds/71a383fdf7ae287aa1f18ebe7e270b0520da2536.json
index 52a9cd3..26c50c0 100755
--- a/src/fixtures/builds/71a383fdf7ae287aa1f18ebe7e270b0520da2536.json
+++ b/src/fixtures/builds/71a383fdf7ae287aa1f18ebe7e270b0520da2536.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "71a383fdf7ae287aa1f18ebe7e270b0520da2536",
-    "timestamp": 1501961911000,
+    "timestamp": 1501961911,
     "parentRevision": "d691af0754ce7dcc08c76a1ed2d8259962cb9d1d"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/7f68d84c59b4604ecd88943eae4a88a41b137ed6.json b/src/fixtures/builds/7f68d84c59b4604ecd88943eae4a88a41b137ed6.json
index d9de423..d9f2376 100755
--- a/src/fixtures/builds/7f68d84c59b4604ecd88943eae4a88a41b137ed6.json
+++ b/src/fixtures/builds/7f68d84c59b4604ecd88943eae4a88a41b137ed6.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "7f68d84c59b4604ecd88943eae4a88a41b137ed6",
-    "timestamp": 1501964690000,
+    "timestamp": 1501964690,
     "parentRevision": "5113828d6b0e628a78fa383a53933c413b6bdbef"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/8de1726f26559b4191f4826817df64baff07711d.json b/src/fixtures/builds/8de1726f26559b4191f4826817df64baff07711d.json
index d8a7e60..9bf1586 100755
--- a/src/fixtures/builds/8de1726f26559b4191f4826817df64baff07711d.json
+++ b/src/fixtures/builds/8de1726f26559b4191f4826817df64baff07711d.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "8de1726f26559b4191f4826817df64baff07711d",
-    "timestamp": 1501962698000,
+    "timestamp": 1501962698,
     "parentRevision": "b4098b3a46c207924e1e9edfa22fc21960524c72"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/90aedcf89f12d2013f58105e0b1493e85c72e92c.json b/src/fixtures/builds/90aedcf89f12d2013f58105e0b1493e85c72e92c.json
index ebdf5fb..0c7a2d6 100755
--- a/src/fixtures/builds/90aedcf89f12d2013f58105e0b1493e85c72e92c.json
+++ b/src/fixtures/builds/90aedcf89f12d2013f58105e0b1493e85c72e92c.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "90aedcf89f12d2013f58105e0b1493e85c72e92c",
-    "timestamp": 1501963830000,
+    "timestamp": 1501963830,
     "parentRevision": "1bf811ec249c2b13a314e127312b2d760f6658e2"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/946f0519038552aac5d36b99dc1e5dbd23bba6bc.json b/src/fixtures/builds/946f0519038552aac5d36b99dc1e5dbd23bba6bc.json
index 2843406..1de5489 100755
--- a/src/fixtures/builds/946f0519038552aac5d36b99dc1e5dbd23bba6bc.json
+++ b/src/fixtures/builds/946f0519038552aac5d36b99dc1e5dbd23bba6bc.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "946f0519038552aac5d36b99dc1e5dbd23bba6bc",
-    "timestamp": 1501950666000,
+    "timestamp": 1501950666,
     "parentRevision": "f7cb67d8312045fe3526cd7c8039c31a3183b03d"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/99649d52f3bf158766c90120b7c3a60d34d6f76c.json b/src/fixtures/builds/99649d52f3bf158766c90120b7c3a60d34d6f76c.json
index ad3e4da..9ccd7e3 100755
--- a/src/fixtures/builds/99649d52f3bf158766c90120b7c3a60d34d6f76c.json
+++ b/src/fixtures/builds/99649d52f3bf158766c90120b7c3a60d34d6f76c.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "99649d52f3bf158766c90120b7c3a60d34d6f76c",
-    "timestamp": 1501957646000,
+    "timestamp": 1501957646,
     "parentRevision": "3d7d22d51db1369426c4ae1a300180c325e0b61d"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/9d5afc23011f42b7794f137e78af45527cb3b58b.json b/src/fixtures/builds/9d5afc23011f42b7794f137e78af45527cb3b58b.json
index 943151b..2ae9742 100755
--- a/src/fixtures/builds/9d5afc23011f42b7794f137e78af45527cb3b58b.json
+++ b/src/fixtures/builds/9d5afc23011f42b7794f137e78af45527cb3b58b.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "9d5afc23011f42b7794f137e78af45527cb3b58b",
-    "timestamp": 1501953075000,
+    "timestamp": 1501953075,
     "parentRevision": "4a8882483a664401a602f64a882d0ed7fb1763cb"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8.json b/src/fixtures/builds/a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8.json
index 7ee7354..3bdcc9e 100755
--- a/src/fixtures/builds/a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8.json
+++ b/src/fixtures/builds/a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8",
-    "timestamp": 1501948399000,
+    "timestamp": 1501948399,
     "parentRevision": "298dfe16af18f420dfbb192d2f38b1ac6022abb8"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/b4098b3a46c207924e1e9edfa22fc21960524c72.json b/src/fixtures/builds/b4098b3a46c207924e1e9edfa22fc21960524c72.json
index eda77b2..64b5a0e 100755
--- a/src/fixtures/builds/b4098b3a46c207924e1e9edfa22fc21960524c72.json
+++ b/src/fixtures/builds/b4098b3a46c207924e1e9edfa22fc21960524c72.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "b4098b3a46c207924e1e9edfa22fc21960524c72",
-    "timestamp": 1501963501000,
+    "timestamp": 1501963501,
     "parentRevision": "90aedcf89f12d2013f58105e0b1493e85c72e92c"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/bd691addbd384df852e116f4452c8e0e615d1b6f.json b/src/fixtures/builds/bd691addbd384df852e116f4452c8e0e615d1b6f.json
index e680a02..2c28b9c 100755
--- a/src/fixtures/builds/bd691addbd384df852e116f4452c8e0e615d1b6f.json
+++ b/src/fixtures/builds/bd691addbd384df852e116f4452c8e0e615d1b6f.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "bd691addbd384df852e116f4452c8e0e615d1b6f",
-    "timestamp": 1501958063000,
+    "timestamp": 1501958063,
     "parentRevision": "4d06472b21aef812ffb2ce70dbd41a08529fbfe6"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b.json b/src/fixtures/builds/bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b.json
index 96a25af..4d898dc 100755
--- a/src/fixtures/builds/bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b.json
+++ b/src/fixtures/builds/bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "bf4c66a3ee2a8c8355f8bd1c207593d32fa29d6b",
-    "timestamp": 1501964531000,
+    "timestamp": 1501964531,
     "parentRevision": "703baac11f1be4b2d02ddb89de6789c7605183b0"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/c00dc16fb85a6de1e51e9eb0f13056443614769e.json b/src/fixtures/builds/c00dc16fb85a6de1e51e9eb0f13056443614769e.json
index 47e52ac..74dabef 100755
--- a/src/fixtures/builds/c00dc16fb85a6de1e51e9eb0f13056443614769e.json
+++ b/src/fixtures/builds/c00dc16fb85a6de1e51e9eb0f13056443614769e.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "c00dc16fb85a6de1e51e9eb0f13056443614769e",
-    "timestamp": 1501948179000,
+    "timestamp": 1501948179,
     "parentRevision": "a7f9952a68c69bbdfd73e7f8dee230d281b0c2b8"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/c0bdda3979f0badcbc3338c66b826ee1d5903e2a.json b/src/fixtures/builds/c0bdda3979f0badcbc3338c66b826ee1d5903e2a.json
index 23538f3..c5d377e 100755
--- a/src/fixtures/builds/c0bdda3979f0badcbc3338c66b826ee1d5903e2a.json
+++ b/src/fixtures/builds/c0bdda3979f0badcbc3338c66b826ee1d5903e2a.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "c0bdda3979f0badcbc3338c66b826ee1d5903e2a",
-    "timestamp": 1501959039000,
+    "timestamp": 1501959039,
     "parentRevision": "6666692feabaa56483f2d21eaec994bf75ac2583"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/c74d7997da43e8b51b66b09a75cb24f2b49fe645.json b/src/fixtures/builds/c74d7997da43e8b51b66b09a75cb24f2b49fe645.json
index 6065369..23c30b9 100755
--- a/src/fixtures/builds/c74d7997da43e8b51b66b09a75cb24f2b49fe645.json
+++ b/src/fixtures/builds/c74d7997da43e8b51b66b09a75cb24f2b49fe645.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "c74d7997da43e8b51b66b09a75cb24f2b49fe645",
-    "timestamp": 1501952939000,
+    "timestamp": 1501952939,
     "parentRevision": "9d5afc23011f42b7794f137e78af45527cb3b58b"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/d167fec4ee6a0bec23a06ce6085a93af3379f287.json b/src/fixtures/builds/d167fec4ee6a0bec23a06ce6085a93af3379f287.json
index ed5b3c9..ee4189a 100755
--- a/src/fixtures/builds/d167fec4ee6a0bec23a06ce6085a93af3379f287.json
+++ b/src/fixtures/builds/d167fec4ee6a0bec23a06ce6085a93af3379f287.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "d167fec4ee6a0bec23a06ce6085a93af3379f287",
-    "timestamp": 1501957836000,
+    "timestamp": 1501957836,
     "parentRevision": "bd691addbd384df852e116f4452c8e0e615d1b6f"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/d48a6bde107f8663818fd5d2460ee059365cc5e1.json b/src/fixtures/builds/d48a6bde107f8663818fd5d2460ee059365cc5e1.json
index 73acde7..5e93e0b 100755
--- a/src/fixtures/builds/d48a6bde107f8663818fd5d2460ee059365cc5e1.json
+++ b/src/fixtures/builds/d48a6bde107f8663818fd5d2460ee059365cc5e1.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "d48a6bde107f8663818fd5d2460ee059365cc5e1",
-    "timestamp": 1501958960000,
+    "timestamp": 1501958960,
     "parentRevision": "c0bdda3979f0badcbc3338c66b826ee1d5903e2a"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/d691af0754ce7dcc08c76a1ed2d8259962cb9d1d.json b/src/fixtures/builds/d691af0754ce7dcc08c76a1ed2d8259962cb9d1d.json
index f115b3c..6944b3a 100755
--- a/src/fixtures/builds/d691af0754ce7dcc08c76a1ed2d8259962cb9d1d.json
+++ b/src/fixtures/builds/d691af0754ce7dcc08c76a1ed2d8259962cb9d1d.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "d691af0754ce7dcc08c76a1ed2d8259962cb9d1d",
-    "timestamp": 1501962072000,
+    "timestamp": 1501962072,
     "parentRevision": "8de1726f26559b4191f4826817df64baff07711d"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/f7cb67d8312045fe3526cd7c8039c31a3183b03d.json b/src/fixtures/builds/f7cb67d8312045fe3526cd7c8039c31a3183b03d.json
index 09da90d..d671b30 100755
--- a/src/fixtures/builds/f7cb67d8312045fe3526cd7c8039c31a3183b03d.json
+++ b/src/fixtures/builds/f7cb67d8312045fe3526cd7c8039c31a3183b03d.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "f7cb67d8312045fe3526cd7c8039c31a3183b03d",
-    "timestamp": 1501950770000,
+    "timestamp": 1501950770,
     "parentRevision": "f90b4831ad2bf921d5ccccfffeb61f6486567777"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/f90b4831ad2bf921d5ccccfffeb61f6486567777.json b/src/fixtures/builds/f90b4831ad2bf921d5ccccfffeb61f6486567777.json
index 815893f..f2ac36e 100755
--- a/src/fixtures/builds/f90b4831ad2bf921d5ccccfffeb61f6486567777.json
+++ b/src/fixtures/builds/f90b4831ad2bf921d5ccccfffeb61f6486567777.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "f90b4831ad2bf921d5ccccfffeb61f6486567777",
-    "timestamp": 1501951363000,
+    "timestamp": 1501951363,
     "parentRevision": "fc55ce1d311aa85bd0499c51e230255bbde19ef3"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13.json b/src/fixtures/builds/f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13.json
index 5c69316..5121db1 100755
--- a/src/fixtures/builds/f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13.json
+++ b/src/fixtures/builds/f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "f915ee2df9a9d3db4a8fb1a8b8c0e82f30338e13",
-    "timestamp": 1501948744000,
+    "timestamp": 1501948744,
     "parentRevision": "4c9d2a8fc24d37c967cf0e596f293d3168b7696e"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/fa752a9ce64a440f16e2c3670aa97a28be25c71f.json b/src/fixtures/builds/fa752a9ce64a440f16e2c3670aa97a28be25c71f.json
index 4811357..a578ab0 100755
--- a/src/fixtures/builds/fa752a9ce64a440f16e2c3670aa97a28be25c71f.json
+++ b/src/fixtures/builds/fa752a9ce64a440f16e2c3670aa97a28be25c71f.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "fa752a9ce64a440f16e2c3670aa97a28be25c71f",
-    "timestamp": 1501949772000,
+    "timestamp": 1501949772,
     "parentRevision": "946f0519038552aac5d36b99dc1e5dbd23bba6bc"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/src/fixtures/builds/fc55ce1d311aa85bd0499c51e230255bbde19ef3.json b/src/fixtures/builds/fc55ce1d311aa85bd0499c51e230255bbde19ef3.json
index c76a81c..4c90bd9 100755
--- a/src/fixtures/builds/fc55ce1d311aa85bd0499c51e230255bbde19ef3.json
+++ b/src/fixtures/builds/fc55ce1d311aa85bd0499c51e230255bbde19ef3.json
@@ -2,7 +2,7 @@
   "meta": {
     "branch": "master",
     "revision": "fc55ce1d311aa85bd0499c51e230255bbde19ef3",
-    "timestamp": 1501951656000,
+    "timestamp": 1501951656,
     "parentRevision": "1327ec715a0a2dd204eb7bede5f86bcf9c78f324"
   },
   "artifacts": [
@@ -407,4 +407,4 @@
       }
     }
   ]
-}
\ No newline at end of file
+}
diff --git a/yarn.lock b/yarn.lock
index 784c7fd..824b1e6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1093,6 +1093,13 @@
   dependencies:
     "@types/d3-selection" "*"
 
+"@types/date-fns@^2.6.0":
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/@types/date-fns/-/date-fns-2.6.0.tgz#b062ca46562002909be0c63a6467ed173136acc1"
+  integrity sha1-sGLKRlYgApCb4MY6ZGftFzE2rME=
+  dependencies:
+    date-fns "*"
+
 "@types/dotenv@^6.1.0":
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-6.1.0.tgz#fba2bfd1f28a46eadaa049f3313ebb89bdedfc53"
@@ -3024,7 +3031,7 @@ data-urls@^1.0.0:
     whatwg-mimetype "^2.2.0"
     whatwg-url "^7.0.0"
 
-date-fns@^1.27.2:
+date-fns@*, date-fns@^1.27.2, date-fns@^1.30.1:
   version "1.30.1"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
   integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==


Test patch:
diff --git a/src/app/src/components/Graph/__tests__/index.test.tsx b/src/app/src/components/Graph/__tests__/index.test.tsx
index 6827e0b..b9a721b 100644
--- a/src/app/src/components/Graph/__tests__/index.test.tsx
+++ b/src/app/src/components/Graph/__tests__/index.test.tsx
@@ -33,7 +33,7 @@ describe('Graph', () => {
           sizeKey: 'stat'
         })}
       >
-        <Graph />
+        <Graph comparator={comparator} />
       </StoreContext.Provider>
     );
     fireEvent(getByType(View), 'layout', { nativeEvent: { layout: { width: 400, height: 300 } } });
diff --git a/src/app/src/components/__tests__/DatePicker.test.tsx b/src/app/src/components/__tests__/DatePicker.test.tsx
new file mode 100644
index 0000000..927b197
--- /dev/null
+++ b/src/app/src/components/__tests__/DatePicker.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import DatePicker from '../DatePicker';
+import React from 'react';
+import { View } from 'react-native';
+import { fireEvent, render } from 'react-native-testing-library';
+
+describe('DatePicker', () => {
+  let viewRef;
+  beforeEach(() => {
+    viewRef = React.createRef();
+    render(<View ref={viewRef} />);
+  });
+
+  describe('minDate', () => {
+    test('disables days before the minDate', () => {
+      const { queryAllByProps } = render(
+        <DatePicker minDate={new Date(2019, 2, 26)} relativeTo={viewRef} selectedDate={new Date(2019, 2, 27)} />
+      );
+      expect(queryAllByProps({ accessibilityLabel: '2019-03-25', disabled: true, type: 'text' })).toHaveLength(1);
+    });
+  });
+
+  describe('maxDate', () => {
+    test('disables days after the maxDate', () => {
+      const { queryAllByProps } = render(
+        <DatePicker maxDate={new Date(2019, 2, 26)} relativeTo={viewRef} selectedDate={new Date(2019, 2, 23)} />
+      );
+      expect(queryAllByProps({ accessibilityLabel: '2019-03-27', disabled: true, type: 'text' })).toHaveLength(1);
+    });
+  });
+
+  describe('selectedDate', () => {
+    test('sets a different style for the selected date', () => {
+      const { queryAllByProps } = render(<DatePicker relativeTo={viewRef} selectedDate={new Date(2019, 2, 23)} />);
+      expect(queryAllByProps({ accessibilityLabel: '2019-03-23', disabled: false, type: 'unelevated' })).toHaveLength(
+        1
+      );
+    });
+  });
+
+  describe('navigation', () => {
+    test('next month moves forward', () => {
+      const { getByProps, queryAllByText } = render(
+        <DatePicker relativeTo={viewRef} selectedDate={new Date(2019, 2, 23)} />
+      );
+      expect(queryAllByText('March')).toHaveLength(1);
+      fireEvent.press(getByProps({ title: 'Next month' }));
+      expect(queryAllByText('April')).toHaveLength(1);
+    });
+
+    test('previous month moves forward', () => {
+      const { getByProps, queryAllByText } = render(
+        <DatePicker relativeTo={viewRef} selectedDate={new Date(2019, 2, 23)} />
+      );
+      expect(queryAllByText('March')).toHaveLength(1);
+      fireEvent.press(getByProps({ title: 'Previous month' }));
+      expect(queryAllByText('February')).toHaveLength(1);
+    });
+  });
+
+  describe('onSelect', () => {
+    test('fires when day button is pressed', () => {
+      const handleSelect = jest.fn();
+      const { getByProps } = render(
+        <DatePicker onSelect={handleSelect} relativeTo={viewRef} selectedDate={new Date(2019, 2, 23)} />
+      );
+      fireEvent.press(getByProps({ accessibilityLabel: '2019-03-24', disabled: false }));
+      expect(handleSelect).toHaveBeenCalledWith(new Date(2019, 2, 24));
+    });
+  });
+});
diff --git a/src/app/src/components/__tests__/DateTextField.test.tsx b/src/app/src/components/__tests__/DateTextField.test.tsx
new file mode 100644
index 0000000..41da88f
--- /dev/null
+++ b/src/app/src/components/__tests__/DateTextField.test.tsx
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import DatePicker from '../DatePicker';
+import DateTextField from '../DateTextField';
+import React from 'react';
+import { TextInput } from 'react-native';
+import { fireEvent, flushMicrotasksQueue, render } from 'react-native-testing-library';
+
+describe('DateTextField', () => {
+  describe('onBlur', () => {
+    test('hides the date picker', async () => {
+      const { getByType, queryAllByType } = render(<DateTextField label="foobar" onSet={jest.fn()} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      expect(queryAllByType(DatePicker)).toHaveLength(1);
+      fireEvent(getByType(TextInput), 'blur');
+      expect(queryAllByType(DatePicker)).toHaveLength(0);
+    });
+  });
+
+  describe('onChangeText', () => {
+    test('calls onSet for a valid date', async () => {
+      const handleSet = jest.fn();
+      const { getByType } = render(<DateTextField label="foobar" onSet={handleSet} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      fireEvent(getByType(TextInput), 'changeText', '2019-03-26');
+      expect(handleSet).toHaveBeenCalledWith(new Date('2019-03-26'));
+      expect(getByType(DatePicker).props.selectedDate).toEqual(new Date('2019-03-26'));
+    });
+
+    test('does not call onSet for invalid date', async () => {
+      const handleSet = jest.fn();
+      const { getByType } = render(<DateTextField label="foobar" onSet={handleSet} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      fireEvent(getByType(TextInput), 'changeText', '2019-0');
+      expect(handleSet).not.toHaveBeenCalled();
+      expect(getByType(DatePicker).props.selectedDate).toBeUndefined();
+    });
+  });
+
+  describe('when selecting a date', () => {
+    test('hides the date picker', async () => {
+      const { getByType, queryAllByType } = render(<DateTextField label="foobar" onSet={jest.fn()} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      expect(queryAllByType(DatePicker)).toHaveLength(1);
+
+      fireEvent(getByType(DatePicker), 'select', new Date());
+      expect(queryAllByType(DatePicker)).toHaveLength(0);
+    });
+
+    test('calls onSet', async () => {
+      const handleSet = jest.fn();
+      const { getByType, queryAllByType } = render(<DateTextField label="foobar" onSet={handleSet} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      expect(queryAllByType(DatePicker)).toHaveLength(1);
+
+      const now = new Date();
+      fireEvent(getByType(DatePicker), 'select', now);
+      expect(handleSet).toHaveBeenCalledWith(now);
+    });
+
+    test('sets the text field value', async () => {
+      const { getByType, queryAllByType } = render(<DateTextField label="foobar" onSet={jest.fn()} />);
+      fireEvent(getByType(TextInput), 'focus');
+      await flushMicrotasksQueue();
+      expect(queryAllByType(DatePicker)).toHaveLength(1);
+
+      fireEvent(getByType(DatePicker), 'select', new Date(2019, 2, 26));
+      expect(getByType(TextInput).props.value).toBe('2019-03-26');
+    });
+  });
+});
diff --git a/src/app/src/components/__tests__/EmptyState.test.tsx b/src/app/src/components/__tests__/EmptyState.test.tsx
new file mode 100644
index 0000000..4cb3597
--- /dev/null
+++ b/src/app/src/components/__tests__/EmptyState.test.tsx
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import EmptyState from '../EmptyState';
+import React from 'react';
+import { render } from 'react-native-testing-library';
+
+describe('EmptyState', () => {
+  test('renders a message', () => {
+    const { queryAllByText } = render(<EmptyState message="tacos rule" />);
+    expect(queryAllByText('tacos rule')).toHaveLength(1);
+  });
+});
diff --git a/src/app/src/components/__tests__/RelativeModal.test.tsx b/src/app/src/components/__tests__/RelativeModal.test.tsx
new file mode 100644
index 0000000..4831abb
--- /dev/null
+++ b/src/app/src/components/__tests__/RelativeModal.test.tsx
@@ -0,0 +1,142 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import RelativeModal from '../RelativeModal';
+import { Dimensions, MeasureOnSuccessCallback, Text, View } from 'react-native';
+import { fireEvent, render } from 'react-testing-library';
+
+describe('RelativeModal', () => {
+  let viewRef;
+  beforeEach(() => {
+    viewRef = React.createRef();
+    render(<View ref={viewRef} />);
+  });
+
+  test('renders in a portal', () => {
+    const portal = document.createElement('div');
+    portal.setAttribute('id', 'menuPortal');
+    document.body.appendChild(portal);
+
+    jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { queryAllByText } = render(
+      <RelativeModal relativeTo={viewRef}>
+        <Text>tacos</Text>
+      </RelativeModal>,
+      {
+        container: portal
+      }
+    );
+
+    expect(queryAllByText('tacos')).toHaveLength(1);
+  });
+
+  test('renders directly without a portal available', () => {
+    jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { queryAllByText } = render(
+      <RelativeModal relativeTo={viewRef}>
+        <Text>tacos</Text>
+        <Text>burritos</Text>
+      </RelativeModal>
+    );
+
+    expect(queryAllByText('tacos')).toHaveLength(1);
+    expect(queryAllByText('burritos')).toHaveLength(1);
+  });
+
+  test('clicking outside calls dismiss', () => {
+    const handleDismiss = jest.fn();
+    const { getByTestId } = render(
+      <RelativeModal onDismiss={handleDismiss} relativeTo={viewRef}>
+        <Text>tacos</Text>
+      </RelativeModal>
+    );
+    fireEvent.touchStart(getByTestId('overlay'));
+    fireEvent.touchEnd(getByTestId('overlay'));
+    expect(handleDismiss).toHaveBeenCalledTimes(1);
+  });
+
+  describe('render position', () => {
+    test('avoids right window edge', () => {
+      jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
+      jest.spyOn(View.prototype, 'measure').mockImplementation(
+        (fn: MeasureOnSuccessCallback): void => {
+          fn(0, 0, 50, 30, 0, 0);
+        }
+      );
+      jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
+        (fn: (x: number, y: number, width: number, height: number) => void): void => {
+          fn(380, 200, 50, 10);
+        }
+      );
+      const { getByRole } = render(
+        <RelativeModal accessibilityRole="menu" onDismiss={jest.fn()} relativeTo={viewRef}>
+          <Text>tacos</Text>
+        </RelativeModal>
+      );
+
+      expect(getByRole('menu').style).toMatchObject({
+        top: '210px',
+        left: '350px'
+      });
+    });
+
+    test('avoids left window edge', () => {
+      jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
+      jest.spyOn(View.prototype, 'measure').mockImplementation(
+        (fn: MeasureOnSuccessCallback): void => {
+          fn(0, 0, 45, 30, 0, 0);
+        }
+      );
+      jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
+        (fn: (x: number, y: number, width: number, height: number) => void): void => {
+          fn(-10, 200, 20, 10);
+        }
+      );
+      const { getByRole } = render(
+        <RelativeModal accessibilityRole="menu" onDismiss={jest.fn()} relativeTo={viewRef}>
+          <Text>tacos</Text>
+        </RelativeModal>
+      );
+
+      expect(getByRole('menu').style).toMatchObject({
+        top: '210px',
+        left: '0px'
+      });
+    });
+
+    test('avoids bottom window edge', () => {
+      jest.spyOn(Dimensions, 'get').mockReturnValue({ width: 400, height: 400, scale: 1, fontScale: 1 });
+      jest.spyOn(View.prototype, 'measure').mockImplementation(
+        (fn: MeasureOnSuccessCallback): void => {
+          fn(0, 0, 45, 30, 0, 0);
+        }
+      );
+      jest.spyOn(viewRef.current, 'measureInWindow').mockImplementation(
+        (fn: (x: number, y: number, width: number, height: number) => void): void => {
+          fn(200, 390, 20, 10);
+        }
+      );
+      const { getByRole } = render(
+        <RelativeModal accessibilityRole="menu" onDismiss={jest.fn()} relativeTo={viewRef}>
+          <Text>tacos</Text>
+        </RelativeModal>
+      );
+
+      expect(getByRole('menu').style).toMatchObject({
+        top: '360px',
+        left: '200px'
+      });
+    });
+  });
+});
diff --git a/src/app/src/store/__tests__/reducer.test.ts b/src/app/src/store/__tests__/reducer.test.ts
index 30e8c07..c6a048a 100644
--- a/src/app/src/store/__tests__/reducer.test.ts
+++ b/src/app/src/store/__tests__/reducer.test.ts
@@ -16,6 +16,7 @@ const initialState: State = Object.freeze({
   colorScale: Object.keys(ColorScale)[0],
   comparator: new Comparator({ builds: [] }),
   comparedRevisions: [],
+  dateRange: undefined,
   disabledArtifactsVisible: true,
   hoveredArtifacts: [],
   snacks: [],
@@ -170,6 +171,23 @@ describe('reducer', () => {
     });
   });
 
+  describe('date range', () => {
+    test('sets the date range', () => {
+      const start = new Date(2018);
+      const end = new Date(2019);
+      const state = reducer(initialState, Actions.setDateRange(start, end));
+      expect(state.dateRange).toEqual({ start, end });
+    });
+
+    test('clears the comparedRevisions', () => {
+      const state = reducer(
+        { ...initialState, comparedRevisions: ['123', '456', '789'] },
+        Actions.setDateRange(new Date(2017), new Date(2019))
+      );
+      expect(state.comparedRevisions).toHaveLength(0);
+    });
+  });
+
   describe('unknown actions', () => {
     test('returns the state', () => {
       // @ts-ignore
diff --git a/src/app/src/views/__tests__/Graph.test.tsx b/src/app/src/views/__tests__/Graph.test.tsx
new file mode 100644
index 0000000..1d84962
--- /dev/null
+++ b/src/app/src/views/__tests__/Graph.test.tsx
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import Build from '@build-tracker/build';
+import Comparator from '@build-tracker/comparator';
+import EmptyState from '../../components/EmptyState';
+import Graph from '../Graph';
+import mockStore from '../../store/mock';
+import React from 'react';
+import { render } from 'react-native-testing-library';
+import { StoreContext } from 'redux-react-hook';
+
+const build = new Build({ revision: '1234565', parentRevision: 'abcdef', timestamp: 123 }, []);
+
+describe('Graph', () => {
+  test('renders an empty message if no builds in comparator', () => {
+    const { queryAllByType } = render(
+      <StoreContext.Provider value={mockStore({ comparator: new Comparator({ builds: [] }) })}>
+        <Graph />
+      </StoreContext.Provider>
+    );
+    expect(queryAllByType(EmptyState)).toHaveLength(1);
+  });
+
+  test('does not render an empty message if there are builds in comparator', () => {
+    const { queryAllByType } = render(
+      <StoreContext.Provider
+        value={mockStore({ activeArtifacts: {}, colorScale: 'Magma', comparator: new Comparator({ builds: [build] }) })}
+      >
+        <Graph />
+      </StoreContext.Provider>
+    );
+    expect(queryAllByType(EmptyState)).toHaveLength(0);
+  });
+});
diff --git a/src/build/src/__tests__/index.test.ts b/src/build/src/__tests__/index.test.ts
index 91f36b3..6d42e80 100644
--- a/src/build/src/__tests__/index.test.ts
+++ b/src/build/src/__tests__/index.test.ts
@@ -9,11 +9,11 @@ const burritoArtifact = { name: 'burritos', hash: '123', sizes: { stat: 3, gzip:
 describe('Build', () => {
   let baseMeta;
   beforeEach(() => {
-    jest.spyOn(Date, 'now').mockReturnValue(1550528708828);
+    jest.spyOn(Date, 'now').mockReturnValue(1550528708000);
     baseMeta = {
       revision: '123',
       parentRevision: { value: 'abc', url: 'https://build-tracker.local' },
-      timestamp: Date.now()
+      timestamp: 1550528708
     };
   });
 
@@ -28,7 +28,7 @@ describe('Build', () => {
     test('gets the timestamp as a Date', () => {
       const build = new Build(baseMeta, []);
       expect(build.timestamp).toBeInstanceOf(Date);
-      expect(build.timestamp).toEqual(new Date(Date.now()));
+      expect(build.timestamp).toEqual(new Date(1550528708000));
     });
   });
 
diff --git a/src/comparator/src/__tests__/index.test.ts b/src/comparator/src/__tests__/index.test.ts
index 54315e5..935844f 100644
--- a/src/comparator/src/__tests__/index.test.ts
+++ b/src/comparator/src/__tests__/index.test.ts
@@ -64,6 +64,11 @@ describe('BuildComparator', () => {
       const comparator = new BuildComparator({ builds: [build1, build2] });
       expect(comparator.artifactNames).toEqual(['churros', 'burritos', 'tacos']);
     });
+
+    test('returns an empty array if there are no builds', () => {
+      const comparator = new BuildComparator({ builds: [] });
+      expect(comparator.artifactNames).toEqual([]);
+    });
   });
 
   describe('sizeKeys', () => {
@@ -73,10 +78,23 @@ describe('BuildComparator', () => {
     });
 
     test('throws an error if some builds have size keys that others do not', () => {
+      expect(() => {
+        new BuildComparator({
+          builds: [new Build(build1.meta, [{ name: 'tacos', hash: 'abc', sizes: { tacos: 123 } }]), build2]
+        });
+      }).toThrowErrorMatchingInlineSnapshot(`"builds provided do not have same size keys for artifacts"`);
+    });
+
+    test('returns an empty array if there are no builds', () => {
+      const comparator = new BuildComparator({ builds: [] });
+      expect(comparator.sizeKeys).toEqual([]);
+    });
+
+    test('returns an empty array if there are no artifacts', () => {
       const comparator = new BuildComparator({
-        builds: [new Build(build1.meta, [{ name: 'tacos', hash: 'abc', sizes: { tacos: 123 } }]), build2]
+        builds: [new Build(build1.meta, [])]
       });
-      expect(() => comparator.sizeKeys).toThrow();
+      expect(comparator.sizeKeys).toEqual([]);
     });
   });
 
@@ -93,6 +111,11 @@ describe('BuildComparator', () => {
       expect(comparator.buildDeltas[1]).toHaveLength(1);
       expect(comparator.buildDeltas[1][0]).toBeInstanceOf(BuildDelta);
     });
+
+    test('returns an empty array if there are no builds', () => {
+      const comparator = new BuildComparator({ builds: [] });
+      expect(comparator.buildDeltas).toEqual([]);
+    });
   });
 
   describe('matrixHeader', () => {
@@ -349,7 +372,7 @@ describe('BuildComparator', () => {
 "|         |  12 |  89 |    d1 |
 | :------ | --: | --: | ----: |
 | All     | 579 | 592 |  0.21 |
-| churros |     | 469 |  1.00 |
+| churros |   0 | 469 |  1.00 |
 | tacos   | 123 | 123 | -0.04 |"
 `);
     });
@@ -419,7 +442,7 @@ tacos,0.04 KiB,0.04 KiB,0 KiB (-4.4%)"
         .toMatchInlineSnapshot(`
 ",12,89,d1
 All,579,592,0.21
-churros,,469,1.00
+churros,0,469,1.00
 tacos,123,123,-0.04"
 `);
     });