Repository: paularmstrong/build-tracker
Pull request #164 (2020-03-15 16:18:47)

[chore] add README files that point to the docs
<!--
Thank you so much for contributing to open source and the Build Tracker project!
-->

# Problem

There are no README files in published versions, which is not great when looking on yarnpkg.com or npmjs.com

# Solution

I don't really want to keep every README up to date with docs as well as the actual docs site. For now, just giving a short description and pointing to the buildtracker.dev site.

# TODO

- [ ] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation


github-actions[bot]: <!-- sentinel:build-tracker-action -->
undefined: `Group "Web App"` failed the gzip budget size limit of 150 KiB by 2.69 KiB

<details><summary>View table</summary>

|         |    72665d9 |    138d5da |           Δ1 |
| :------ | ---------: | ---------: | -----------: |
| All     | 474.01 KiB | 474.01 KiB | 0 KiB (0.0%) |
| Web App | 152.69 KiB | 152.69 KiB | 0 KiB (0.0%) |

</details>

View on [<img src="https://buildtracker.dev/img/favicon.png" alt="" width="20" height="20" /> Build Tracker](https://build-tracker-demo.herokuapp.com/builds/72665d95db9078e74a38b195e412488aeea9cde8/138d5da894123a0d062292ad9f1581d6355e10ea?comparedRevisions=72665d95db9078e74a38b195e412488aeea9cde8&comparedRevisions=138d5da894123a0d062292ad9f1581d6355e10ea)

Patch:
diff --git a/demo/build-tracker.config.js b/demo/build-tracker.config.js
index 31edafb..47d758c 100644
--- a/demo/build-tracker.config.js
+++ b/demo/build-tracker.config.js
@@ -7,7 +7,7 @@ module.exports = withPostgres({
       {
         name: 'Web App',
         artifactMatch: /^app\/client/,
-        budgets: [{ level: BudgetLevel.WARNING, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 153600 }]
+        budgets: [{ level: BudgetLevel.WARN, sizeKey: 'gzip', type: BudgetType.SIZE, maximum: 153600 }]
       }
     ]
   },
diff --git a/docs/docs/guides/guides.md b/docs/docs/guides/guides.md
index 7f00786..bf14dd2 100644
--- a/docs/docs/guides/guides.md
+++ b/docs/docs/guides/guides.md
@@ -7,6 +7,7 @@ sidebar_label: Guides
 > This page is currently just a placeholder.
 > It is a jumping point for guides and ideas for doing more with Build Tracker. If you have ideas for more, please help by [contributing](contributing)!
 
+- [Deploy to Heroku](heroku)
 - [GitHub Actions](github-actions)
 - [Continuous Integration](advanced-ci)
 - [Contributing](contributing)
diff --git a/docs/docs/guides/heroku.md b/docs/docs/guides/heroku.md
new file mode 100644
index 0000000..56757bb
--- /dev/null
+++ b/docs/docs/guides/heroku.md
@@ -0,0 +1,33 @@
+---
+id: heroku
+title: Deploy to Heroku
+sidebar_label: Deploy to Heroku
+---
+
+## Prerequisites
+
+This documentation assumes that you have an active Heroku account.
+
+## 1. Create a new repository from the template
+
+On GitHub, [create a new repository using the template](https://github.com/paularmstrong/build-tracker-heroku/generate) from [paularmstrong/build-tracker-heroku](https://github.com/paularmstrong/build-tracker-heroku).
+
+## 2. Update the `build-tracker.config.js` file
+
+This is the `@build-tracker/server` configuration. The most important thing to do here is to set up your [performance budgets](/docs/budgets). There are also [more options](/docs/packages/server#configuration) available that you may want to set as well. It is not recommended to change any of the presets other than the `artifacts` option.
+
+## 3. Deploy to Heroku
+
+If you are set up as a public GitHub repository, simply click the _Deploy to Heroku_ button at the top of the README.md file while viewing it on GitHub.
+
+If you are using a private repository, update the `README.md` link to use the `?template=` argument, replacing the repository `my-org/my-repository` in this string with the correct organization and repository path:
+
+```
+https://heroku.com/deploy?template=https://github.com/my-org/my-repository/tree/master
+```
+
+Once that URL is updated, you may click it any time.
+
+## 4. Configure on Heroku
+
+Follow the instructions provided on Heroku to deploy your Build Tracker application. Take special note that the `BT_URL` option must be provided with the same application name if you plan on using a herokuapp.com domain. If you are going to configure any other domain, enter that instead.
diff --git a/docs/sidebars.js b/docs/sidebars.js
index b5f380a..33a3169 100644
--- a/docs/sidebars.js
+++ b/docs/sidebars.js
@@ -1,7 +1,7 @@
 module.exports = {
   docs: {
     'Getting Started': ['installation', 'budgets'],
-    Guides: ['guides/guides', 'guides/github-actions', 'guides/advanced-ci', 'guides/contributing'],
+    Guides: ['guides/guides', 'guides/heroku', 'guides/github-actions', 'guides/advanced-ci', 'guides/contributing'],
     Packages: [
       'packages/api-client',
       'packages/api-errors',
diff --git a/plugins/with-mariadb/README.md b/plugins/with-mariadb/README.md
index 8b24874..7861662 100644
--- a/plugins/with-mariadb/README.md
+++ b/plugins/with-mariadb/README.md
@@ -1,53 +1,5 @@
 # @build-tracker/plugin-with-mariadb
 
-A server-configuration plugin for Build Tracker to enable reading build data from a MariaDB database.
+> A server-configuration plugin for Build Tracker to enable reading build data from a MariaDB database.
 
-Connecting your Build Tracker application to a Maria database is easy with the help of `@build-tracker/plugin-with-mariadb`
-
-## Installation
-
-```sh
-yarn add @build-tracker/plugin-with-mariadb@latest
-# or
-npm install --save @build-tracker/plugin-with-mariadb@latest
-```
-
-## Configuration
-
-Edit your `build-tracker.config.js` file and compose your output configuration:
-
-```js
-const withMariadb = require('@build-tracker/plugin-with-mariadb');
-
-module.exports = withMariadb({
-  mariadb: {
-    user: '', // default: process.env.MARIAUSER
-    host: '', // default: process.env.MARIAHOST
-    database: '', // default: process.env.MARIADATABASE
-    password: '', // default: process.env.MARIAPASSWORD
-    port: 3306 // default: process.env.MARIAPORT
-  }
-});
-```
-
-All configuration options that are able to fall back on `process.env` environment variables can be written to your systems `ENV` or to a local `.env` file via [dotenv](https://github.com/motdotla/dotenv#readme).
-
-### `host: string = process.env.MARIAHOST`
-
-Database host.
-
-### `database: string = process.env.MARIAPASSWORD`
-
-Database name.
-
-### `user: string = process.env.MARIAUSER`
-
-Database username with read access.
-
-### `password: string = process.env.MARIADATABASE`
-
-Password for the given database username.
-
-### `port: number = process.env.MARIAPORT = 3306`
-
-Database host port.
+Read the [documentation online](https://buildtracker.dev/docs/plugins/withMariadb) for more information on getting started with Build Tracker.
diff --git a/plugins/with-mysql/README.md b/plugins/with-mysql/README.md
index 2fe86ba..de4f1d5 100644
--- a/plugins/with-mysql/README.md
+++ b/plugins/with-mysql/README.md
@@ -1,53 +1,5 @@
 # @build-tracker/plugin-with-mysql
 
-A server-configuration plugin for Build Tracker to enable reading build data from a MySQL database.
+> A server-configuration plugin for Build Tracker to enable reading build data from a MySQL database.
 
-Connecting your Build Tracker application to a MySQL database is easy with the help of `@build-tracker/plugin-with-mysql`
-
-## Installation
-
-```sh
-yarn add @build-tracker/plugin-with-mysql@latest
-# or
-npm install --save @build-tracker/plugin-with-mysql@latest
-```
-
-## Configuration
-
-Edit your `build-tracker.config.js` file and compose your output configuration:
-
-```js
-const withMysql = require('@build-tracker/plugin-with-mysql');
-
-module.exports = withMysql({
-  mysql: {
-    user: '', // default: process.env.MYSQLUSER
-    host: '', // default: process.env.MYSQLHOST
-    database: '', // default: process.env.MYSQLDATABASE
-    password: '', // default: process.env.MYSQLPASSWORD
-    port: 3306 // default: process.env.MYSQLPORT
-  }
-});
-```
-
-All configuration options that are able to fall back on `process.env` environment variables can be written to your systems `ENV` or to a local `.env` file via [dotenv](https://github.com/motdotla/dotenv#readme).
-
-### `host: string = process.env.MYSQLHOST`
-
-Database host.
-
-### `database: string = process.env.MYSQLPASSWORD`
-
-Database name.
-
-### `user: string = process.env.MYSQLUSER`
-
-Database username with read access.
-
-### `password: string = process.env.MYSQLDATABASE`
-
-Password for the given database username.
-
-### `port: number = process.env.MYSQLPORT = 3306`
-
-Database host port.
+Read the [documentation online](https://buildtracker.dev/docs/plugins/withMysql) for more information on getting started with Build Tracker.
diff --git a/plugins/with-postgres/README.md b/plugins/with-postgres/README.md
index e348dfc..5a1e1bc 100644
--- a/plugins/with-postgres/README.md
+++ b/plugins/with-postgres/README.md
@@ -1,53 +1,5 @@
 # @build-tracker/plugin-with-postgres
 
-A server-configuration plugin for Build Tracker to enable reading build data from a Postgres database.
+> A server-configuration plugin for Build Tracker to enable reading build data from a PostgreSQL database.
 
-Wrap your server's `build-tracker.config.js` configuration with `withPostgres` and include the `pg` options object:
-
-```js
-const withPostgres = require('@build-tracker/plugin-with-postgres');
-
-module.exports = withPostgres({
-  pg: {
-    connectionString: '', // default: process.env.DATABASE_URL
-    user: '', // default: process.env.PGUSER
-    host: '', // default: process.env.PGHOST
-    database: '', // default: process.env.PGPASSWORD
-    password: '', // default: process.env.PGDATABASE
-    port: 5432, // default: process.env.PGPORT
-    ssl: true
-  }
-});
-```
-
-## Configuration
-
-All configuration options that are able to fall back on `process.env` environment variables can be written to your systems `ENV` or to a local `.env` file via [dotenv](https://github.com/motdotla/dotenv#readme).
-
-### `connectionString: string = process.env.DATABASE_URL`
-
-Optional. Use a single connection string to bypass the individual configs for `host`, `database`, `user`, `password`, and `port`.
-
-### `host: string = process.env.PGHOST`
-
-Database host.
-
-### `database: string = process.env.PGPASSWORD`
-
-Database name.
-
-### `user: string = process.env.PGUSER`
-
-Database username with read access.
-
-### `password: string = process.env.PGDATABASE`
-
-Password for the given database username.
-
-### `port: number = process.env.PGPORT = 5432`
-
-Database host port.
-
-### `ssl: boolean = false`
-
-Set to true to connect to your host using SSL (if supported).
+Read the [documentation online](https://buildtracker.dev/docs/plugins/withPostgres) for more information on getting started with Build Tracker.
diff --git a/src/api-client/README.md b/src/api-client/README.md
new file mode 100644
index 0000000..c01d545
--- /dev/null
+++ b/src/api-client/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/api-client
+
+> A suite of tools for interfacing with a [Build Tracker](https://buildtracker.dev) instance's API.
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/api-client) for more information on getting started with Build Tracker.
diff --git a/src/api-errors/README.md b/src/api-errors/README.md
new file mode 100644
index 0000000..89897c8
--- /dev/null
+++ b/src/api-errors/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/api-errors
+
+> A comprehensive list of error types that may be returned from a [Build Tracker](https://buildtracker.dev) instance's API.
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/api-errors) for more information on getting started with Build Tracker.
diff --git a/src/app/README.md b/src/app/README.md
new file mode 100644
index 0000000..fa0c368
--- /dev/null
+++ b/src/app/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/app
+
+> A React.js client-side application for [Build Tracker](https://buildtracker.dev).
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/app) for more information on getting started with Build Tracker.
diff --git a/src/app/src/components/ComparisonTable/ArtifactCell.tsx b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
index 2273628..da6d9ee 100644
--- a/src/app/src/components/ComparisonTable/ArtifactCell.tsx
+++ b/src/app/src/components/ComparisonTable/ArtifactCell.tsx
@@ -5,8 +5,8 @@ import * as Theme from '../../theme';
 import Hoverable from '../Hoverable';
 import { hsl } from 'd3-color';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { ArtifactCell as ACell, GroupCell as GCell } from '@build-tracker/comparator';
 import { StyleProp, StyleSheet, Switch, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
 
@@ -61,7 +61,7 @@ export const ArtifactCell = (props: Props): React.ReactElement => {
                   {text}
                 </Text>
               </TouchableOpacity>
-              {isHovered ? <Tooltip relativeTo={textRef} text={`Show "${text}" only`} /> : null}
+              {isHovered ? <RelativeTooltip relativeTo={textRef} text={`Show "${text}" only`} /> : null}
             </View>
           )}
         </Hoverable>
diff --git a/src/app/src/components/ComparisonTable/DeltaCell.tsx b/src/app/src/components/ComparisonTable/DeltaCell.tsx
index ad00eaa..714a6a7 100644
--- a/src/app/src/components/ComparisonTable/DeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/DeltaCell.tsx
@@ -6,8 +6,8 @@ import ErrorIcon from '../../icons/Error';
 import HashIcon from '../../icons/Hash';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Td } from '../Table';
-import Tooltip from '../Tooltip';
 import WarningIcon from '../../icons/Warning';
 import { DeltaCell as Cell, TotalDeltaCell as TDCell } from '@build-tracker/comparator';
 import { formatBudgetResult, formatBytes, formatUnexpectedHashChange } from '@build-tracker/formatting';
@@ -99,7 +99,7 @@ export const DeltaCell = (props: Props): React.ReactElement => {
                 </Text>
               ) : null}
               {sizeDelta !== 0 ? <Text>{text}</Text> : null}
-              {isHovered ? <Tooltip relativeTo={viewRef} text={tooltipText} /> : null}
+              {isHovered ? <RelativeTooltip relativeTo={viewRef} text={tooltipText} /> : null}
             </View>
           )}
         </Hoverable>
diff --git a/src/app/src/components/ComparisonTable/GroupCell.tsx b/src/app/src/components/ComparisonTable/GroupCell.tsx
index 365891e..45d64c5 100644
--- a/src/app/src/components/ComparisonTable/GroupCell.tsx
+++ b/src/app/src/components/ComparisonTable/GroupCell.tsx
@@ -6,8 +6,8 @@ import FolderIcon from '../../icons/Folder';
 import { GroupCell as GCell } from '@build-tracker/comparator';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { StyleProp, StyleSheet, Switch, Text, TouchableOpacity, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -54,7 +54,7 @@ export const GroupCell = (props: Props): React.ReactElement => {
                 <Text style={isHovered && styles.hoveredText}>
                   <FolderIcon style={[styles.folder, isHovered && styles.hoveredText]} testID="groupicon" /> {text}
                 </Text>
-                {isHovered ? <Tooltip relativeTo={nameRef} text={`${artifactNames.join(', ')}`} /> : null}
+                {isHovered ? <RelativeTooltip relativeTo={nameRef} text={`${artifactNames.join(', ')}`} /> : null}
               </View>
             </TouchableOpacity>
           )}
diff --git a/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx b/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
index 1279220..60fc1e3 100644
--- a/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
+++ b/src/app/src/components/ComparisonTable/RevisionDeltaCell.tsx
@@ -5,8 +5,8 @@ import { RevisionDeltaCell as Cell } from '@build-tracker/comparator';
 import { formatSha } from '@build-tracker/formatting';
 import Hoverable from '../Hoverable';
 import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
 import { Th } from '../Table';
-import Tooltip from '../Tooltip';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
@@ -25,7 +25,7 @@ export const RevisionDeltaCell = (props: Props): React.ReactElement => {
           <View ref={viewRef} testID="delta">
             <Text style={styles.delta}>{`𝚫${deltaIndex}`}</Text>
             {isHovered ? (
-              <Tooltip
+              <RelativeTooltip
                 relativeTo={viewRef}
                 text={`Delta from ${formatSha(againstRevision)} to ${formatSha(revision)}`}
               />
diff --git a/src/app/src/components/Graph/HoverOverlay.tsx b/src/app/src/components/Graph/HoverOverlay.tsx
index a96b0c6..01a480b 100644
--- a/src/app/src/components/Graph/HoverOverlay.tsx
+++ b/src/app/src/components/Graph/HoverOverlay.tsx
@@ -3,11 +3,13 @@
  */
 import * as Theme from '../../theme';
 import Build from '@build-tracker/build';
+import { formatSha } from '@build-tracker/formatting';
 import memoize from 'memoize-one';
 import { Offset } from './Offset';
 import React from 'react';
 import { select } from 'd3-selection';
 import { Series } from 'd3-shape';
+import Tooltip from '../Tooltip';
 import { ScaleBand, ScaleLinear, ScalePoint } from 'd3-scale';
 
 interface Props {
@@ -75,12 +77,19 @@ const HoverOverlay = (props: Props): React.ReactElement => {
     [buildRevisionFromX, onSelectRevision, selectedRevisions]
   );
 
+  const [offsetX, setOffsetX] = React.useState(NaN);
+  const [absPosition, setAbsPosition] = React.useState({ left: 0, top: 0 });
+  const [hoveredArtifact, setHoveredArtifact] = React.useState('');
+
   const handleMouseMove = React.useCallback(
     (event: React.MouseEvent<SVGRectElement>): void => {
       const {
-        nativeEvent: { offsetX, offsetY = Offset.TOP }
+        nativeEvent: { offsetX, offsetY = Offset.TOP, pageX, pageY }
       } = event;
 
+      setOffsetX(offsetX);
+      setAbsPosition({ left: pageX, top: pageY });
+
       const revision = buildRevisionFromX(offsetX - Offset.LEFT);
       const xPos = xGetter(revision.value);
       handleMoveLine(lineRef.current, xPos);
@@ -89,6 +98,7 @@ const HoverOverlay = (props: Props): React.ReactElement => {
       const hoveredArtifact = data.find(data => {
         return data[revision.index][0] <= yValue && data[revision.index][1] >= yValue;
       });
+      hoveredArtifact && setHoveredArtifact(hoveredArtifact.key);
       onHoverArtifacts(hoveredArtifact ? [hoveredArtifact.key] : []);
     },
     [buildRevisionFromX, data, onHoverArtifacts, xGetter, yScale]
@@ -97,12 +107,18 @@ const HoverOverlay = (props: Props): React.ReactElement => {
   const handleMouseOut = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 0);
     onHoverArtifacts([]);
+    setOffsetX(NaN);
   }, [onHoverArtifacts]);
 
   const handleMouseOver = React.useCallback((): void => {
     select(lineRef.current).style('opacity', 1);
   }, []);
 
+  const getTooltipText = React.useCallback((): string => {
+    const revision = buildRevisionFromX(offsetX - Offset.LEFT);
+    return `Revision: ${formatSha(revision.value)}, Artifact: ${hoveredArtifact}`;
+  }, [buildRevisionFromX, hoveredArtifact, offsetX]);
+
   return (
     <g>
       <rect
@@ -132,6 +148,7 @@ const HoverOverlay = (props: Props): React.ReactElement => {
         );
       })}
       <line data-testid="hoverline" pointerEvents="none" ref={lineRef} style={styles.hoverLine} y1={0} y2={height} />
+      {!isNaN(offsetX) ? <Tooltip left={absPosition.left} top={absPosition.top} text={getTooltipText()} /> : null}
     </g>
   );
 };
diff --git a/src/app/src/components/RelativeTooltip.tsx b/src/app/src/components/RelativeTooltip.tsx
new file mode 100644
index 0000000..ae77052
--- /dev/null
+++ b/src/app/src/components/RelativeTooltip.tsx
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import Tooltip from './Tooltip';
+import { View } from 'react-native';
+
+interface Props {
+  relativeTo: React.RefObject<View>;
+  text: string;
+}
+
+const RelativeTooltip = (props: Props): React.ReactElement => {
+  const { relativeTo, text } = props;
+  const [position, setPosition] = React.useState({ top: -999, left: 0 });
+
+  React.useEffect(() => {
+    let mounted = true;
+    if (relativeTo.current) {
+      relativeTo.current.measureInWindow(
+        (x: number, y: number, width: number, height: number): void => {
+          if (!mounted) {
+            return;
+          }
+
+          setPosition({ left: x + Math.round(width / 2), top: y + Math.round(height / 2) });
+        }
+      );
+    }
+    return () => {
+      mounted = false;
+    };
+    /* eslint-disable react-hooks/exhaustive-deps */
+    // Tests break if you pass the ref/relativeTo higher object in and not the actual value we're using
+  }, [relativeTo.current]);
+  /* eslint-enable react-hooks/exhaustive-deps */
+
+  return <Tooltip left={position.left} top={position.top} text={text} />;
+};
+
+export default RelativeTooltip;
diff --git a/src/app/src/components/Tooltip.tsx b/src/app/src/components/Tooltip.tsx
index 58eb260..f374c97 100644
--- a/src/app/src/components/Tooltip.tsx
+++ b/src/app/src/components/Tooltip.tsx
@@ -7,61 +7,53 @@ import ReactDOM from 'react-dom';
 import { Dimensions, StyleSheet, Text, View } from 'react-native';
 
 interface Props {
-  relativeTo: React.RefObject<View>;
+  top: number;
+  left: number;
   text: string;
 }
 
-const tipSpace = 6;
+const TIP_SPACE = 6;
 
 const Tooltip = (props: Props): React.ReactElement => {
-  const { relativeTo, text } = props;
+  const { left, text, top } = props;
   const [position, setPosition] = React.useState({ top: -999, left: 0 });
   const portalRoot = document.getElementById('tooltipPortal');
   const ref: React.RefObject<View> = React.createRef();
 
   React.useEffect(() => {
     let mounted = true;
-    if (relativeTo.current) {
-      const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
-      ref.current.measure(
-        (_x: number, _y: number, tipWidth: number, tipHeight: number): void => {
-          if (!mounted) {
-            return;
-          }
-          relativeTo.current.measureInWindow(
-            (x: number, y: number, width: number, height: number): void => {
-              if (!mounted) {
-                return;
-              }
-              let top = y + height + tipSpace;
-              let left = x + width / 2 - tipWidth / 2;
-              // too far right when underneath
-              if (left + tipWidth > windowWidth) {
-                left = x - tipWidth - tipSpace;
-                top = y + height / 2 - tipHeight / 2;
-              }
-              // too far left when underneath
-              else if (left < 0) {
-                left = x + width + tipSpace;
-                top = y + height / 2 - tipHeight / 2;
-              }
-              // too close to bottom
-              else if (top + tipHeight > windowHeight) {
-                top = y - tipHeight - tipSpace;
-              }
-
-              setPosition({ left, top });
-            }
-          );
+    const { width: windowWidth } = Dimensions.get('window');
+    ref.current.measure(
+      (_x: number, _y: number, tipWidth: number, tipHeight: number): void => {
+        if (!mounted) {
+          return;
+        }
+        let newTop = top - TIP_SPACE - tipHeight;
+        let newLeft = left - Math.round(tipWidth / 2);
+        // too far right
+        if (newLeft + tipWidth + TIP_SPACE > windowWidth) {
+          newLeft = left - tipWidth - TIP_SPACE;
+          newTop = top - Math.round(tipHeight / 2);
+        }
+        // too far left
+        else if (newLeft < TIP_SPACE) {
+          newLeft = left + TIP_SPACE;
+          newTop = top - Math.round(tipHeight / 2);
         }
-      );
-    }
+        // too close to top
+        else if (newTop <= TIP_SPACE) {
+          newTop = top + tipHeight + TIP_SPACE;
+        }
+
+        setPosition({ left: newLeft, top: newTop });
+      }
+    );
     return () => {
       mounted = false;
     };
     /* eslint-disable react-hooks/exhaustive-deps */
     // Tests break if you pass the ref/relativeTo higher object in and not the actual value we're using
-  }, [ref.current, relativeTo.current]);
+  }, [ref.current, left, top]);
   /* eslint-enable react-hooks/exhaustive-deps */
 
   const tooltip = (
@@ -81,7 +73,8 @@ const styles = StyleSheet.create({
   root: {
     maxWidth: 400,
     position: 'absolute',
-    backgroundColor: Theme.Color.Gray50,
+    pointerEvents: 'none',
+    backgroundColor: `${Theme.Color.Gray50}EE`,
     borderRadius: Theme.BorderRadius.Normal,
     paddingHorizontal: Theme.Spacing.Small,
     paddingVertical: Theme.Spacing.Xsmall,
diff --git a/src/build/README.md b/src/build/README.md
new file mode 100644
index 0000000..f0e66d7
--- /dev/null
+++ b/src/build/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/build
+
+> A shareable representation of a single `build` used in [Build Tracker](https://buildtracker.dev).
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/build) for more information on getting started with Build Tracker.
diff --git a/src/cli/README.md b/src/cli/README.md
new file mode 100644
index 0000000..1310023
--- /dev/null
+++ b/src/cli/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/cli
+
+> A command-line interface for interacting with a [Build Tracker](https://buildtracker.dev) instance's API.
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/cli) for more information on getting started with Build Tracker.
diff --git a/src/comparator/README.md b/src/comparator/README.md
index 454e640..60fb786 100644
--- a/src/comparator/README.md
+++ b/src/comparator/README.md
@@ -1,11 +1,5 @@
-# `comparator`
+# @build-tracker/comparator
 
-> TODO: description
+> The [Build Tracker](https://buildtracker.dev) Comparator contains the core functionality for calculating how two or more Builds differ. This package is only needed individually if you would like to do some custom reporting on various Builds.
 
-## Usage
-
-```
-const comparator = require('comparator');
-
-// TODO: DEMONSTRATE API
-```
+Read the [documentation online](https://buildtracker.dev/docs/packages/comparator) for more information on getting started with Build Tracker.
diff --git a/src/fixtures/README.md b/src/fixtures/README.md
new file mode 100644
index 0000000..e4ca186
--- /dev/null
+++ b/src/fixtures/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/fixtures
+
+> Static data for testing and developing [Build Tracker](https://buildtracker.dev).
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/fixtures) for more information on getting started with Build Tracker.
diff --git a/src/formatting/README.md b/src/formatting/README.md
new file mode 100644
index 0000000..e58abcb
--- /dev/null
+++ b/src/formatting/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/formatting
+
+> String formatters for making [Build Tracker](https://buildtracker.dev) data human-readable.
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/formatting) for more information on getting started with Build Tracker.
diff --git a/src/server/README.md b/src/server/README.md
new file mode 100644
index 0000000..613e31d
--- /dev/null
+++ b/src/server/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/server
+
+> Runs a [Build Tracker](https://buildtracker.dev) web application (`@build-tracker/app`) using a Node.js server.
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/server) for more information on getting started with Build Tracker.
diff --git a/src/types/README.md b/src/types/README.md
new file mode 100644
index 0000000..14730bf
--- /dev/null
+++ b/src/types/README.md
@@ -0,0 +1,5 @@
+# @build-tracker/types
+
+> Shared types and enums used throughout [Build Tracker](https://buildtracker.dev).
+
+Read the [documentation online](https://buildtracker.dev/docs/packages/types) for more information on getting started with Build Tracker.


Test patch:
diff --git a/src/app/src/components/__tests__/RelativeTooltip.test.tsx b/src/app/src/components/__tests__/RelativeTooltip.test.tsx
new file mode 100644
index 0000000..bc557c7
--- /dev/null
+++ b/src/app/src/components/__tests__/RelativeTooltip.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import React from 'react';
+import RelativeTooltip from '../RelativeTooltip';
+import { render } from '@testing-library/react';
+import { MeasureOnSuccessCallback, View } from 'react-native';
+
+describe('RelativeTooltip', () => {
+  let viewRef;
+  beforeEach(() => {
+    viewRef = React.createRef();
+    render(<View ref={viewRef} />);
+  });
+
+  test('renders a tooltip to a portal', () => {
+    const portal = document.createElement('div');
+    portal.setAttribute('id', 'tooltipPortal');
+    document.body.appendChild(portal);
+
+    jest.spyOn(View.prototype, 'measure').mockImplementation(
+      (fn: MeasureOnSuccessCallback): void => {
+        fn(0, 0, 45, 20, 0, 0);
+      }
+    );
+    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { getByRole, queryAllByText } = render(<RelativeTooltip relativeTo={viewRef} text="foobar" />, {
+      container: portal
+    });
+
+    expect(getByRole('tooltip').style).toMatchObject({
+      top: '89px',
+      left: '17px'
+    });
+    expect(queryAllByText('foobar')).toHaveLength(1);
+    document.body.removeChild(portal);
+  });
+
+  test('renders directly without a portal available', () => {
+    jest.spyOn(View.prototype, 'measure').mockImplementation(
+      (fn: MeasureOnSuccessCallback): void => {
+        fn(0, 0, 45, 20, 0, 0);
+      }
+    );
+
+    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
+      (fn: (x: number, y: number, width: number, height: number) => void): void => {
+        fn(20, 100, 40, 30);
+      }
+    );
+
+    const { getByRole, queryAllByText } = render(<RelativeTooltip relativeTo={viewRef} text="foobar" />);
+
+    expect(getByRole('tooltip').style).toMatchObject({
+      top: '89px',
+      left: '17px'
+    });
+    expect(queryAllByText('foobar')).toHaveLength(1);
+  });
+});
diff --git a/src/app/src/components/__tests__/Tooltip.test.tsx b/src/app/src/components/__tests__/Tooltip.test.tsx
index 5531f45..058bfa3 100644
--- a/src/app/src/components/__tests__/Tooltip.test.tsx
+++ b/src/app/src/components/__tests__/Tooltip.test.tsx
@@ -7,12 +7,6 @@ import Tooltip from '../Tooltip';
 import { Dimensions, MeasureOnSuccessCallback, View } from 'react-native';
 
 describe('Tooltip', () => {
-  let viewRef;
-  beforeEach(() => {
-    viewRef = React.createRef();
-    render(<View ref={viewRef} />);
-  });
-
   test('renders a tooltip to a portal', () => {
     const portal = document.createElement('div');
     portal.setAttribute('id', 'tooltipPortal');
@@ -23,21 +17,17 @@ describe('Tooltip', () => {
         fn(0, 0, 45, 20, 0, 0);
       }
     );
-    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-      (fn: (x: number, y: number, width: number, height: number) => void): void => {
-        fn(20, 100, 40, 30);
-      }
-    );
 
-    const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />, {
+    const { getByRole, queryAllByText } = render(<Tooltip left={20} top={100} text="foobar" />, {
       container: portal
     });
 
     expect(getByRole('tooltip').style).toMatchObject({
-      top: '136px',
-      left: '17.5px'
+      top: '90px',
+      left: '26px'
     });
     expect(queryAllByText('foobar')).toHaveLength(1);
+    document.body.removeChild(portal);
   });
 
   test('renders directly without a portal available', () => {
@@ -47,17 +37,11 @@ describe('Tooltip', () => {
       }
     );
 
-    jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-      (fn: (x: number, y: number, width: number, height: number) => void): void => {
-        fn(20, 100, 40, 30);
-      }
-    );
-
-    const { getByRole, queryAllByText } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+    const { getByRole, queryAllByText } = render(<Tooltip left={20} top={100} text="foobar" />);
 
     expect(getByRole('tooltip').style).toMatchObject({
-      top: '136px',
-      left: '17.5px'
+      top: '90px',
+      left: '26px'
     });
     expect(queryAllByText('foobar')).toHaveLength(1);
   });
@@ -70,17 +54,12 @@ describe('Tooltip', () => {
           fn(0, 0, 45, 30, 0, 0);
         }
       );
-      jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(10, 200, 20, 10);
-        }
-      );
 
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={10} top={200} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '190px',
-        left: '36px'
+        top: '185px',
+        left: '16px'
       });
     });
 
@@ -91,38 +70,27 @@ describe('Tooltip', () => {
           fn(0, 0, 45, 30, 0, 0);
         }
       );
-      jest.spyOn(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(380, 200, 50, 10);
-        }
-      );
 
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={380} top={200} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '190px',
+        top: '185px',
         left: '329px'
       });
     });
 
-    test('avoids the bottom edge', () => {
+    test('avoids the top 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(View.prototype, 'measureInWindow').mockImplementation(
-        (fn: (x: number, y: number, width: number, height: number) => void): void => {
-          fn(200, 380, 50, 10);
-        }
-      );
-
-      const { getByRole } = render(<Tooltip relativeTo={viewRef} text="foobar" />);
+      const { getByRole } = render(<Tooltip left={200} top={0} text="foobar" />);
 
       expect(getByRole('tooltip').style).toMatchObject({
-        top: '344px',
-        left: '202.5px'
+        top: '36px',
+        left: '177px'
       });
     });
   });