Merge "Add some confirm dialogs to scouting page to prevent dangerous actions."
diff --git a/WORKSPACE b/WORKSPACE
index 398202c..58fb14f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1057,3 +1057,20 @@
         "https://github.com/bazelbuild/buildtools/archive/refs/tags/4.2.4.tar.gz",
     ],
 )
+
+http_archive(
+    name = "rules_pkg",
+    patch_args = ["-p1"],
+    patches = [
+        "//third_party:rules_pkg/0001-Fix-tree-artifacts.patch",
+    ],
+    sha256 = "62eeb544ff1ef41d786e329e1536c1d541bb9bcad27ae984d57f18f314018e66",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.6.0/rules_pkg-0.6.0.tar.gz",
+        "https://github.com/bazelbuild/rules_pkg/releases/download/0.6.0/rules_pkg-0.6.0.tar.gz",
+    ],
+)
+
+load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+
+rules_pkg_dependencies()
diff --git a/scouting/BUILD b/scouting/BUILD
index 0ed540b..a769426 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -33,6 +33,7 @@
         "//scouting/www:index.html",
         "//scouting/www:zonejs_copy",
     ],
+    visibility = ["//scouting/deploy:__pkg__"],
 )
 
 protractor_ts_test(
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 788e6e3..d666f79 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -30,87 +30,161 @@
 // Opens a database at the specified path. If the path refers to a non-existent
 // file, the database will be created and initialized with empty tables.
 func NewDatabase(path string) (*Database, error) {
+	var err error
 	database := new(Database)
-	database.DB, _ = sql.Open("sqlite3", path)
-	statement, error_ := database.Prepare("CREATE TABLE IF NOT EXISTS matches " +
-		"(id INTEGER PRIMARY KEY, MatchNumber INTEGER, Round INTEGER, CompLevel INTEGER, R1 INTEGER, R2 INTEGER, R3 INTEGER, B1 INTEGER, B2 INTEGER, B3 INTEGER, r1ID INTEGER, r2ID INTEGER, r3ID INTEGER, b1ID INTEGER, b2ID INTEGER, b3ID INTEGER)")
-	defer statement.Close()
-	if error_ != nil {
-		fmt.Println(error_)
-		return nil, error_
+	database.DB, err = sql.Open("sqlite3", path)
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to create postgres db: ", err))
 	}
-	_, error_ = statement.Exec()
-	statement, error_ = database.Prepare("CREATE TABLE IF NOT EXISTS team_match_stats (id INTEGER PRIMARY KEY, TeamNumber INTEGER, MatchNumber DOUBLE, ShotsMissed INTEGER, UpperGoalShots INTEGER, LowerGoalShots INTEGER, ShotsMissedAuto INTEGER, UpperGoalAuto INTEGER, LowerGoalAuto INTEGER, PlayedDefense INTEGER, Climbing INTEGER)")
-	defer statement.Close()
-	if error_ != nil {
-		fmt.Println(error_)
-		return nil, error_
+
+	statement, err := database.Prepare("CREATE TABLE IF NOT EXISTS matches (" +
+		"id INTEGER PRIMARY KEY, " +
+		"MatchNumber INTEGER, " +
+		"Round INTEGER, " +
+		"CompLevel INTEGER, " +
+		"R1 INTEGER, " +
+		"R2 INTEGER, " +
+		"R3 INTEGER, " +
+		"B1 INTEGER, " +
+		"B2 INTEGER, " +
+		"B3 INTEGER, " +
+		"r1ID INTEGER, " +
+		"r2ID INTEGER, " +
+		"r3ID INTEGER, " +
+		"b1ID INTEGER, " +
+		"b2ID INTEGER, " +
+		"b3ID INTEGER)")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to prepare matches table creation: ", err))
 	}
-	_, error_ = statement.Exec()
+	defer statement.Close()
+
+	_, err = statement.Exec()
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to create matches table: ", err))
+	}
+
+	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_match_stats (" +
+		"id INTEGER PRIMARY KEY, " +
+		"TeamNumber INTEGER, " +
+		"MatchNumber INTEGER, " +
+		"ShotsMissed INTEGER, " +
+		"UpperGoalShots INTEGER, " +
+		"LowerGoalShots INTEGER, " +
+		"ShotsMissedAuto INTEGER, " +
+		"UpperGoalAuto INTEGER, " +
+		"LowerGoalAuto INTEGER, " +
+		"PlayedDefense INTEGER, " +
+		"Climbing INTEGER)")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to prepare stats table creation: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec()
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to create team_match_stats table: ", err))
+	}
+
 	return database, nil
 }
 
 func (database *Database) Delete() error {
-	statement, error_ := database.Prepare("DROP TABLE IF EXISTS matches")
-	if error_ != nil {
-		fmt.Println(error_)
-		return (error_)
+	statement, err := database.Prepare("DROP TABLE IF EXISTS matches")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare dropping matches table: ", err))
 	}
-	_, error_ = statement.Exec()
-	statement, error_ = database.Prepare("DROP TABLE IF EXISTS team_match_stats")
-	if error_ != nil {
-		fmt.Println(error_)
-		return (error_)
+	_, err = statement.Exec()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to drop matches table: ", err))
 	}
-	_, error_ = statement.Exec()
+
+	statement, err = database.Prepare("DROP TABLE IF EXISTS team_match_stats")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare dropping stats table: ", err))
+	}
+	_, err = statement.Exec()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to drop stats table: ", err))
+	}
 	return nil
 }
 
 // This function will also populate the Stats table with six empty rows every time a match is added
 func (database *Database) AddToMatch(m Match) error {
-	statement, error_ := database.Prepare("INSERT INTO team_match_stats(TeamNumber, MatchNumber, ShotsMissed, UpperGoalShots, LowerGoalShots, ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto, PlayedDefense, Climbing) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
-	defer statement.Close()
-	if error_ != nil {
-		fmt.Println("failed to prepare stats database:", error_)
-		return (error_)
+	statement, err := database.Prepare("INSERT INTO team_match_stats(" +
+		"TeamNumber, MatchNumber, " +
+		"ShotsMissed, UpperGoalShots, LowerGoalShots, " +
+		"ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto, " +
+		"PlayedDefense, Climbing) " +
+		"VALUES (" +
+		"?, ?, " +
+		"?, ?, ?, " +
+		"?, ?, ?, " +
+		"?, ?)")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare insertion into stats database: ", err))
 	}
+	defer statement.Close()
+
 	var rowIds [6]int64
 	for i, TeamNumber := range []int32{m.R1, m.R2, m.R3, m.B1, m.B2, m.B3} {
-		result, error_ := statement.Exec(TeamNumber, m.MatchNumber, 0, 0, 0, 0, 0, 0, 0, 0)
-		if error_ != nil {
-			fmt.Println("failed to execute statement 2:", error_)
-			return (error_)
+		result, err := statement.Exec(TeamNumber, m.MatchNumber, 0, 0, 0, 0, 0, 0, 0, 0)
+		if err != nil {
+			return errors.New(fmt.Sprint("Failed to insert stats: ", err))
 		}
-		rowIds[i], error_ = result.LastInsertId()
+		rowIds[i], err = result.LastInsertId()
+		if err != nil {
+			return errors.New(fmt.Sprint("Failed to get last insert ID: ", err))
+		}
 	}
-	statement, error_ = database.Prepare("INSERT INTO matches(MatchNumber, Round, CompLevel, R1, R2, R3, B1, B2, B3, r1ID, r2ID, r3ID, b1ID, b2ID, b3ID) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
+
+	statement, err = database.Prepare("INSERT INTO matches(" +
+		"MatchNumber, Round, CompLevel, " +
+		"R1, R2, R3, B1, B2, B3, " +
+		"r1ID, r2ID, r3ID, b1ID, b2ID, b3ID) " +
+		"VALUES (" +
+		"?, ?, ?, " +
+		"?, ?, ?, ?, ?, ?, " +
+		"?, ?, ?, ?, ?, ?)")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare insertion into match database: ", err))
+	}
 	defer statement.Close()
-	if error_ != nil {
-		fmt.Println("failed to prepare match database:", error_)
-		return (error_)
-	}
-	_, error_ = statement.Exec(m.MatchNumber, m.Round, m.CompLevel, m.R1, m.R2, m.R3, m.B1, m.B2, m.B3, rowIds[0], rowIds[1], rowIds[2], rowIds[3], rowIds[4], rowIds[5])
-	if error_ != nil {
-		fmt.Println(error_)
-		return (error_)
+
+	_, err = statement.Exec(m.MatchNumber, m.Round, m.CompLevel,
+		m.R1, m.R2, m.R3, m.B1, m.B2, m.B3,
+		rowIds[0], rowIds[1], rowIds[2], rowIds[3], rowIds[4], rowIds[5])
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to insert into match database: ", err))
 	}
 	return nil
 }
 
 func (database *Database) AddToStats(s Stats) error {
-	statement, error_ := database.Prepare("UPDATE team_match_stats SET TeamNumber = ?, MatchNumber = ?, ShotsMissed = ?, UpperGoalShots = ?, LowerGoalShots = ?, ShotsMissedAuto = ?, UpperGoalAuto = ?, LowerGoalAuto = ?, PlayedDefense = ?, Climbing = ? WHERE MatchNumber = ? AND TeamNumber = ?")
-	if error_ != nil {
-		fmt.Println(error_)
-		return (error_)
+	statement, err := database.Prepare("UPDATE team_match_stats SET " +
+		"TeamNumber = ?, MatchNumber = ?, " +
+		"ShotsMissed = ?, UpperGoalShots = ?, LowerGoalShots = ?, " +
+		"ShotsMissedAuto = ?, UpperGoalAuto = ?, LowerGoalAuto = ?, " +
+		"PlayedDefense = ?, Climbing = ? " +
+		"WHERE MatchNumber = ? AND TeamNumber = ?")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare stats update statement: ", err))
 	}
-	result, error_ := statement.Exec(s.TeamNumber, s.MatchNumber, s.ShotsMissed, s.UpperGoalShots, s.LowerGoalShots, s.ShotsMissedAuto, s.UpperGoalAuto, s.LowerGoalAuto, s.PlayedDefense, s.Climbing, s.MatchNumber, s.TeamNumber)
-	if error_ != nil {
-		fmt.Println(error_)
-		return (error_)
+	defer statement.Close()
+
+	result, err := statement.Exec(s.TeamNumber, s.MatchNumber,
+		s.ShotsMissed, s.UpperGoalShots, s.LowerGoalShots,
+		s.ShotsMissedAuto, s.UpperGoalAuto, s.LowerGoalAuto,
+		s.PlayedDefense, s.Climbing,
+		s.MatchNumber, s.TeamNumber)
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to update stats database: ", err))
 	}
-	numRowsAffected, error_ := result.RowsAffected()
-	if error_ != nil {
-		return errors.New(fmt.Sprint("Failed to query rows affected: ", error_))
+
+	numRowsAffected, err := result.RowsAffected()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to query rows affected: ", err))
 	}
 	if numRowsAffected == 0 {
 		return errors.New(fmt.Sprint(
@@ -121,16 +195,21 @@
 }
 
 func (database *Database) ReturnMatches() ([]Match, error) {
-	matches := make([]Match, 0)
-	rows, _ := database.Query("SELECT * FROM matches")
+	rows, err := database.Query("SELECT * FROM matches")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from matches: ", err))
+	}
 	defer rows.Close()
+
+	matches := make([]Match, 0)
 	for rows.Next() {
 		var match Match
 		var id int
-		error_ := rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel, &match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3, &match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
-		if error_ != nil {
-			fmt.Println(nil, error_)
-			return nil, error_
+		err := rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel,
+			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3,
+			&match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
 		}
 		matches = append(matches, match)
 	}
@@ -138,19 +217,22 @@
 }
 
 func (database *Database) ReturnStats() ([]Stats, error) {
-	rows, error_ := database.Query("SELECT * FROM team_match_stats")
-	if error_ != nil {
-		return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM team_match_stats: ", error_))
+	rows, err := database.Query("SELECT * FROM team_match_stats")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM team_match_stats: ", err))
 	}
 	defer rows.Close()
+
 	teams := make([]Stats, 0)
-	var id int
 	for rows.Next() {
 		var team Stats
-		error_ := rows.Scan(&id, &team.TeamNumber, &team.MatchNumber, &team.ShotsMissed, &team.UpperGoalShots, &team.LowerGoalShots, &team.ShotsMissedAuto, &team.UpperGoalAuto, &team.LowerGoalAuto, &team.PlayedDefense, &team.Climbing)
-		if error_ != nil {
-			fmt.Println(error_)
-			return nil, error_
+		var id int
+		err = rows.Scan(&id, &team.TeamNumber, &team.MatchNumber,
+			&team.ShotsMissed, &team.UpperGoalShots, &team.LowerGoalShots,
+			&team.ShotsMissedAuto, &team.UpperGoalAuto, &team.LowerGoalAuto,
+			&team.PlayedDefense, &team.Climbing)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from stats: ", err))
 		}
 		teams = append(teams, team)
 	}
@@ -158,41 +240,48 @@
 }
 
 func (database *Database) QueryMatches(teamNumber_ int32) ([]Match, error) {
-	rows, error_ := database.Query("SELECT * FROM matches WHERE R1 = ? OR R2 = ? OR R3 = ? OR B1 = ? OR B2 = ? OR B3 = ?", teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_)
-	if error_ != nil {
-		fmt.Println("failed to execute statement 1:", error_)
-		return nil, error_
+	rows, err := database.Query("SELECT * FROM matches WHERE "+
+		"R1 = ? OR R2 = ? OR R3 = ? OR B1 = ? OR B2 = ? OR B3 = ?",
+		teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_)
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from matches for team: ", err))
 	}
 	defer rows.Close()
+
 	var matches []Match
-	var id int
 	for rows.Next() {
 		var match Match
-		rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel, &match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3, &match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
+		var id int
+		err = rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel,
+			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3,
+			&match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
+		}
 		matches = append(matches, match)
 	}
 	return matches, nil
 }
 
 func (database *Database) QueryStats(teamNumber_ int) ([]Stats, error) {
-	rows, error_ := database.Query("SELECT * FROM team_match_stats WHERE TeamNumber = ?", teamNumber_)
-	if error_ != nil {
-		fmt.Println("failed to execute statement 3:", error_)
-		return nil, error_
+	rows, err := database.Query("SELECT * FROM team_match_stats WHERE TeamNumber = ?", teamNumber_)
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from stats: ", err))
 	}
 	defer rows.Close()
+
 	var teams []Stats
 	for rows.Next() {
 		var team Stats
 		var id int
-		error_ = rows.Scan(&id, &team.TeamNumber, &team.MatchNumber, &team.ShotsMissed,
-			&team.UpperGoalShots, &team.LowerGoalShots, &team.ShotsMissedAuto, &team.UpperGoalAuto,
-			&team.LowerGoalAuto, &team.PlayedDefense, &team.Climbing)
+		err = rows.Scan(&id, &team.TeamNumber, &team.MatchNumber,
+			&team.ShotsMissed, &team.UpperGoalShots, &team.LowerGoalShots,
+			&team.ShotsMissedAuto, &team.UpperGoalAuto, &team.LowerGoalAuto,
+			&team.PlayedDefense, &team.Climbing)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from stats: ", err))
+		}
 		teams = append(teams, team)
 	}
-	if error_ != nil {
-		fmt.Println("failed to execute statement 3:", error_)
-		return nil, error_
-	}
 	return teams, nil
 }
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 6e3e8c3..474ea41 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -1,21 +1,28 @@
 package db
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"reflect"
 	"testing"
 )
 
+// Shortcut for error checking. If the specified error is non-nil, print the
+// error message and exit the test.
+func check(t *testing.T, err error, message string) {
+	if err != nil {
+		t.Fatal(message, ":", err)
+	}
+}
+
 // Creates a database in TEST_TMPDIR so that we don't accidentally write it
 // into the runfiles directory.
 func createDatabase(t *testing.T) *Database {
 	// Get the path to our temporary writable directory.
 	testTmpdir := os.Getenv("TEST_TMPDIR")
 	db, err := NewDatabase(filepath.Join(testTmpdir, "scouting.db"))
-	if err != nil {
-		t.Fatal("Failed to create a new database: ", err)
-	}
+	check(t, err, "Failed to create new database")
 	return db
 }
 
@@ -23,12 +30,22 @@
 	db := createDatabase(t)
 	defer db.Delete()
 
-	correct := []Match{Match{MatchNumber: 7, Round: 1, CompLevel: "quals", R1: 9999, R2: 1000, R3: 777, B1: 0000, B2: 4321, B3: 1234, r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6}}
-	db.AddToMatch(correct[0])
-	got, error_ := db.ReturnMatches()
-	if error_ != nil {
-		t.Fatalf(error_.Error())
+	correct := []Match{
+		Match{
+			MatchNumber: 7,
+			Round:       1,
+			CompLevel:   "quals",
+			R1:          9999, R2: 1000, R3: 777, B1: 0000, B2: 4321, B3: 1234,
+			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
+		},
 	}
+
+	err := db.AddToMatch(correct[0])
+	check(t, err, "Failed to add match data")
+
+	got, err := db.ReturnMatches()
+	check(t, err, "Failed ReturnMatches()")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
 	}
@@ -39,26 +56,58 @@
 	defer db.Delete()
 
 	correct := []Stats{
-		Stats{TeamNumber: 1236, MatchNumber: 7, ShotsMissed: 9, UpperGoalShots: 5, LowerGoalShots: 4, ShotsMissedAuto: 3, UpperGoalAuto: 2, LowerGoalAuto: 1, PlayedDefense: 2, Climbing: 3},
-		Stats{TeamNumber: 1001, MatchNumber: 7, ShotsMissed: 6, UpperGoalShots: 9, LowerGoalShots: 9, ShotsMissedAuto: 0, UpperGoalAuto: 0, LowerGoalAuto: 0, PlayedDefense: 0, Climbing: 0},
-		Stats{TeamNumber: 777, MatchNumber: 7, ShotsMissed: 5, UpperGoalShots: 7, LowerGoalShots: 12, ShotsMissedAuto: 0, UpperGoalAuto: 4, LowerGoalAuto: 0, PlayedDefense: 0, Climbing: 0},
-		Stats{TeamNumber: 1000, MatchNumber: 7, ShotsMissed: 12, UpperGoalShots: 6, LowerGoalShots: 10, ShotsMissedAuto: 0, UpperGoalAuto: 7, LowerGoalAuto: 0, PlayedDefense: 0, Climbing: 0},
-		Stats{TeamNumber: 4321, MatchNumber: 7, ShotsMissed: 14, UpperGoalShots: 12, LowerGoalShots: 3, ShotsMissedAuto: 0, UpperGoalAuto: 7, LowerGoalAuto: 0, PlayedDefense: 0, Climbing: 0},
-		Stats{TeamNumber: 1234, MatchNumber: 7, ShotsMissed: 3, UpperGoalShots: 4, LowerGoalShots: 0, ShotsMissedAuto: 0, UpperGoalAuto: 9, LowerGoalAuto: 0, PlayedDefense: 0, Climbing: 0},
+		Stats{
+			TeamNumber: 1236, MatchNumber: 7,
+			ShotsMissed: 9, UpperGoalShots: 5, LowerGoalShots: 4,
+			ShotsMissedAuto: 3, UpperGoalAuto: 2, LowerGoalAuto: 1,
+			PlayedDefense: 2, Climbing: 3,
+		},
+		Stats{
+			TeamNumber: 1001, MatchNumber: 7,
+			ShotsMissed: 6, UpperGoalShots: 9, LowerGoalShots: 9,
+			ShotsMissedAuto: 0, UpperGoalAuto: 0, LowerGoalAuto: 0,
+			PlayedDefense: 0, Climbing: 0,
+		},
+		Stats{
+			TeamNumber: 777, MatchNumber: 7,
+			ShotsMissed: 5, UpperGoalShots: 7, LowerGoalShots: 12,
+			ShotsMissedAuto: 0, UpperGoalAuto: 4, LowerGoalAuto: 0,
+			PlayedDefense: 0, Climbing: 0,
+		},
+		Stats{
+			TeamNumber: 1000, MatchNumber: 7,
+			ShotsMissed: 12, UpperGoalShots: 6, LowerGoalShots: 10,
+			ShotsMissedAuto: 0, UpperGoalAuto: 7, LowerGoalAuto: 0,
+			PlayedDefense: 0, Climbing: 0,
+		},
+		Stats{
+			TeamNumber: 4321, MatchNumber: 7,
+			ShotsMissed: 14, UpperGoalShots: 12, LowerGoalShots: 3,
+			ShotsMissedAuto: 0, UpperGoalAuto: 7, LowerGoalAuto: 0,
+			PlayedDefense: 0, Climbing: 0,
+		},
+		Stats{
+			TeamNumber: 1234, MatchNumber: 7,
+			ShotsMissed: 3, UpperGoalShots: 4, LowerGoalShots: 0,
+			ShotsMissedAuto: 0, UpperGoalAuto: 9, LowerGoalAuto: 0,
+			PlayedDefense: 0, Climbing: 0,
+		},
 	}
-	err := db.AddToMatch(Match{MatchNumber: 7, Round: 1, CompLevel: "quals", R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234, r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6})
-	if err != nil {
-		t.Fatal("Failed to add match: ", err)
-	}
+
+	err := db.AddToMatch(Match{
+		MatchNumber: 7, Round: 1, CompLevel: "quals",
+		R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234,
+		r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6})
+	check(t, err, "Failed to add match")
+
 	for i := 0; i < len(correct); i++ {
-		if err := db.AddToStats(correct[i]); err != nil {
-			t.Fatal("Failed to add stats to DB: ", err)
-		}
+		err = db.AddToStats(correct[i])
+		check(t, err, "Failed to add stats to DB")
 	}
-	got, error_ := db.ReturnStats()
-	if error_ != nil {
-		t.Fatalf(error_.Error())
-	}
+
+	got, err := db.ReturnStats()
+	check(t, err, "Failed ReturnStats()")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
 	}
@@ -76,18 +125,26 @@
 	}
 
 	for i := 0; i < len(testDatabase); i++ {
-		db.AddToMatch(testDatabase[i])
+		err := db.AddToMatch(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add match", i))
 	}
 
 	correct := []Match{
-		Match{MatchNumber: 2, Round: 1, CompLevel: "quals", R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149, r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6},
-		Match{MatchNumber: 3, Round: 1, CompLevel: "quals", R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262, r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18},
+		Match{
+			MatchNumber: 2, Round: 1, CompLevel: "quals",
+			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
+			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
+		},
+		Match{
+			MatchNumber: 3, Round: 1, CompLevel: "quals",
+			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
+			r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18,
+		},
 	}
 
-	got, error_ := db.QueryMatches(538)
-	if error_ != nil {
-		t.Fatal("Failed to query matches for 538: ", error_)
-	}
+	got, err := db.QueryMatches(538)
+	check(t, err, "Failed to query matches for 538")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
 	}
@@ -98,24 +155,65 @@
 	defer db.Delete()
 
 	testDatabase := []Stats{
-		Stats{TeamNumber: 1235, MatchNumber: 94, ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2, ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2, PlayedDefense: 2, Climbing: 2},
-		Stats{TeamNumber: 1234, MatchNumber: 94, ShotsMissed: 4, UpperGoalShots: 4, LowerGoalShots: 4, ShotsMissedAuto: 4, UpperGoalAuto: 4, LowerGoalAuto: 4, PlayedDefense: 7, Climbing: 2},
-		Stats{TeamNumber: 1233, MatchNumber: 94, ShotsMissed: 3, UpperGoalShots: 3, LowerGoalShots: 3, ShotsMissedAuto: 3, UpperGoalAuto: 3, LowerGoalAuto: 3, PlayedDefense: 3, Climbing: 3},
-		Stats{TeamNumber: 1232, MatchNumber: 94, ShotsMissed: 5, UpperGoalShots: 5, LowerGoalShots: 5, ShotsMissedAuto: 5, UpperGoalAuto: 5, LowerGoalAuto: 5, PlayedDefense: 7, Climbing: 1},
-		Stats{TeamNumber: 1231, MatchNumber: 94, ShotsMissed: 6, UpperGoalShots: 6, LowerGoalShots: 6, ShotsMissedAuto: 6, UpperGoalAuto: 6, LowerGoalAuto: 6, PlayedDefense: 7, Climbing: 1},
-		Stats{TeamNumber: 1239, MatchNumber: 94, ShotsMissed: 7, UpperGoalShots: 7, LowerGoalShots: 7, ShotsMissedAuto: 7, UpperGoalAuto: 7, LowerGoalAuto: 3, PlayedDefense: 7, Climbing: 1},
+		Stats{
+			TeamNumber: 1235, MatchNumber: 94,
+			ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2,
+			ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2,
+			PlayedDefense: 2, Climbing: 2},
+		Stats{
+			TeamNumber: 1234, MatchNumber: 94,
+			ShotsMissed: 4, UpperGoalShots: 4, LowerGoalShots: 4,
+			ShotsMissedAuto: 4, UpperGoalAuto: 4, LowerGoalAuto: 4,
+			PlayedDefense: 7, Climbing: 2,
+		},
+		Stats{
+			TeamNumber: 1233, MatchNumber: 94,
+			ShotsMissed: 3, UpperGoalShots: 3, LowerGoalShots: 3,
+			ShotsMissedAuto: 3, UpperGoalAuto: 3, LowerGoalAuto: 3,
+			PlayedDefense: 3, Climbing: 3,
+		},
+		Stats{
+			TeamNumber: 1232, MatchNumber: 94,
+			ShotsMissed: 5, UpperGoalShots: 5, LowerGoalShots: 5,
+			ShotsMissedAuto: 5, UpperGoalAuto: 5, LowerGoalAuto: 5,
+			PlayedDefense: 7, Climbing: 1,
+		},
+		Stats{
+			TeamNumber: 1231, MatchNumber: 94,
+			ShotsMissed: 6, UpperGoalShots: 6, LowerGoalShots: 6,
+			ShotsMissedAuto: 6, UpperGoalAuto: 6, LowerGoalAuto: 6,
+			PlayedDefense: 7, Climbing: 1,
+		},
+		Stats{
+			TeamNumber: 1239, MatchNumber: 94,
+			ShotsMissed: 7, UpperGoalShots: 7, LowerGoalShots: 7,
+			ShotsMissedAuto: 7, UpperGoalAuto: 7, LowerGoalAuto: 3,
+			PlayedDefense: 7, Climbing: 1,
+		},
 	}
-	db.AddToMatch(Match{MatchNumber: 94, Round: 1, CompLevel: "quals", R1: 1235, R2: 1234, R3: 1233, B1: 1232, B2: 1231, B3: 1239})
+
+	err := db.AddToMatch(Match{
+		MatchNumber: 94, Round: 1, CompLevel: "quals",
+		R1: 1235, R2: 1234, R3: 1233, B1: 1232, B2: 1231, B3: 1239})
+	check(t, err, "Failed to add match")
+
 	for i := 0; i < len(testDatabase); i++ {
-		db.AddToStats(testDatabase[i])
+		err = db.AddToStats(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add stats", i))
 	}
+
 	correct := []Stats{
-		Stats{TeamNumber: 1235, MatchNumber: 94, ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2, ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2, PlayedDefense: 2, Climbing: 2},
+		Stats{
+			TeamNumber: 1235, MatchNumber: 94,
+			ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2,
+			ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2,
+			PlayedDefense: 2, Climbing: 2,
+		},
 	}
-	got, error_ := db.QueryStats(1235)
-	if error_ != nil {
-		t.Fatalf(error_.Error())
-	}
+
+	got, err := db.QueryStats(1235)
+	check(t, err, "Failed QueryStats()")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
 	}
@@ -126,19 +224,41 @@
 	defer db.Delete()
 
 	correct := []Match{
-		Match{MatchNumber: 2, Round: 1, CompLevel: "quals", R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149, r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6},
-		Match{MatchNumber: 3, Round: 1, CompLevel: "quals", R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262, r1ID: 7, r2ID: 8, r3ID: 9, b1ID: 10, b2ID: 11, b3ID: 12},
-		Match{MatchNumber: 4, Round: 1, CompLevel: "quals", R1: 251, R2: 169, R3: 286, B1: 653, B2: 538, B3: 149, r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18},
-		Match{MatchNumber: 5, Round: 1, CompLevel: "quals", R1: 198, R2: 1421, R3: 538, B1: 26, B2: 448, B3: 262, r1ID: 19, r2ID: 20, r3ID: 21, b1ID: 22, b2ID: 23, b3ID: 24},
-		Match{MatchNumber: 6, Round: 1, CompLevel: "quals", R1: 251, R2: 188, R3: 286, B1: 555, B2: 538, B3: 149, r1ID: 25, r2ID: 26, r3ID: 27, b1ID: 28, b2ID: 29, b3ID: 30},
+		Match{
+			MatchNumber: 2, Round: 1, CompLevel: "quals",
+			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
+			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
+		},
+		Match{
+			MatchNumber: 3, Round: 1, CompLevel: "quals",
+			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
+			r1ID: 7, r2ID: 8, r3ID: 9, b1ID: 10, b2ID: 11, b3ID: 12,
+		},
+		Match{
+			MatchNumber: 4, Round: 1, CompLevel: "quals",
+			R1: 251, R2: 169, R3: 286, B1: 653, B2: 538, B3: 149,
+			r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18,
+		},
+		Match{
+			MatchNumber: 5, Round: 1, CompLevel: "quals",
+			R1: 198, R2: 1421, R3: 538, B1: 26, B2: 448, B3: 262,
+			r1ID: 19, r2ID: 20, r3ID: 21, b1ID: 22, b2ID: 23, b3ID: 24,
+		},
+		Match{
+			MatchNumber: 6, Round: 1, CompLevel: "quals",
+			R1: 251, R2: 188, R3: 286, B1: 555, B2: 538, B3: 149,
+			r1ID: 25, r2ID: 26, r3ID: 27, b1ID: 28, b2ID: 29, b3ID: 30,
+		},
 	}
+
 	for i := 0; i < len(correct); i++ {
-		db.AddToMatch(correct[i])
+		err := db.AddToMatch(correct[i])
+		check(t, err, fmt.Sprint("Failed to add match", i))
 	}
-	got, error_ := db.ReturnMatches()
-	if error_ != nil {
-		t.Fatalf(error_.Error())
-	}
+
+	got, err := db.ReturnMatches()
+	check(t, err, "Failed ReturnMatches()")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
 	}
@@ -149,21 +269,57 @@
 	defer db.Delete()
 
 	correct := []Stats{
-		Stats{TeamNumber: 1235, MatchNumber: 94, ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2, ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2, PlayedDefense: 2, Climbing: 2},
-		Stats{TeamNumber: 1236, MatchNumber: 94, ShotsMissed: 4, UpperGoalShots: 4, LowerGoalShots: 4, ShotsMissedAuto: 4, UpperGoalAuto: 4, LowerGoalAuto: 4, PlayedDefense: 7, Climbing: 2},
-		Stats{TeamNumber: 1237, MatchNumber: 94, ShotsMissed: 3, UpperGoalShots: 3, LowerGoalShots: 3, ShotsMissedAuto: 3, UpperGoalAuto: 3, LowerGoalAuto: 3, PlayedDefense: 3, Climbing: 3},
-		Stats{TeamNumber: 1238, MatchNumber: 94, ShotsMissed: 5, UpperGoalShots: 5, LowerGoalShots: 5, ShotsMissedAuto: 5, UpperGoalAuto: 5, LowerGoalAuto: 5, PlayedDefense: 7, Climbing: 1},
-		Stats{TeamNumber: 1239, MatchNumber: 94, ShotsMissed: 6, UpperGoalShots: 6, LowerGoalShots: 6, ShotsMissedAuto: 6, UpperGoalAuto: 6, LowerGoalAuto: 6, PlayedDefense: 7, Climbing: 1},
-		Stats{TeamNumber: 1233, MatchNumber: 94, ShotsMissed: 7, UpperGoalShots: 7, LowerGoalShots: 7, ShotsMissedAuto: 7, UpperGoalAuto: 7, LowerGoalAuto: 3, PlayedDefense: 7, Climbing: 1},
+		Stats{
+			TeamNumber: 1235, MatchNumber: 94,
+			ShotsMissed: 2, UpperGoalShots: 2, LowerGoalShots: 2,
+			ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2,
+			PlayedDefense: 2, Climbing: 2,
+		},
+		Stats{
+			TeamNumber: 1236, MatchNumber: 94,
+			ShotsMissed: 4, UpperGoalShots: 4, LowerGoalShots: 4,
+			ShotsMissedAuto: 4, UpperGoalAuto: 4, LowerGoalAuto: 4,
+			PlayedDefense: 7, Climbing: 2,
+		},
+		Stats{
+			TeamNumber: 1237, MatchNumber: 94,
+			ShotsMissed: 3, UpperGoalShots: 3, LowerGoalShots: 3,
+			ShotsMissedAuto: 3, UpperGoalAuto: 3, LowerGoalAuto: 3,
+			PlayedDefense: 3, Climbing: 3,
+		},
+		Stats{
+			TeamNumber: 1238, MatchNumber: 94,
+			ShotsMissed: 5, UpperGoalShots: 5, LowerGoalShots: 5,
+			ShotsMissedAuto: 5, UpperGoalAuto: 5, LowerGoalAuto: 5,
+			PlayedDefense: 7, Climbing: 1,
+		},
+		Stats{
+			TeamNumber: 1239, MatchNumber: 94,
+			ShotsMissed: 6, UpperGoalShots: 6, LowerGoalShots: 6,
+			ShotsMissedAuto: 6, UpperGoalAuto: 6, LowerGoalAuto: 6,
+			PlayedDefense: 7, Climbing: 1,
+		},
+		Stats{
+			TeamNumber: 1233, MatchNumber: 94,
+			ShotsMissed: 7, UpperGoalShots: 7, LowerGoalShots: 7,
+			ShotsMissedAuto: 7, UpperGoalAuto: 7, LowerGoalAuto: 3,
+			PlayedDefense: 7, Climbing: 1,
+		},
 	}
-	db.AddToMatch(Match{MatchNumber: 94, Round: 1, CompLevel: "quals", R1: 1235, R2: 1236, R3: 1237, B1: 1238, B2: 1239, B3: 1233})
+
+	err := db.AddToMatch(Match{
+		MatchNumber: 94, Round: 1, CompLevel: "quals",
+		R1: 1235, R2: 1236, R3: 1237, B1: 1238, B2: 1239, B3: 1233})
+	check(t, err, "Failed to add match")
+
 	for i := 0; i < len(correct); i++ {
-		db.AddToStats(correct[i])
+		err = db.AddToStats(correct[i])
+		check(t, err, fmt.Sprint("Failed to add stats", i))
 	}
-	got, error_ := db.ReturnStats()
-	if error_ != nil {
-		t.Fatalf(error_.Error())
-	}
+
+	got, err := db.ReturnStats()
+	check(t, err, "Failed ReturnStats()")
+
 	if !reflect.DeepEqual(correct, got) {
 		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
 	}
diff --git a/scouting/deploy/BUILD b/scouting/deploy/BUILD
new file mode 100644
index 0000000..ed4b9cd
--- /dev/null
+++ b/scouting/deploy/BUILD
@@ -0,0 +1,60 @@
+load("@rules_pkg//pkg:pkg.bzl", "pkg_deb", "pkg_tar")
+load("@rules_pkg//pkg:mappings.bzl", "pkg_files")
+
+pkg_files(
+    name = "systemd_files",
+    srcs = [
+        "scouting.service",
+    ],
+    prefix = "etc/systemd/system",
+)
+
+pkg_tar(
+    name = "server_files",
+    srcs = [
+        "//scouting",
+    ],
+    include_runfiles = True,
+    package_dir = "opt/frc971/scouting_server",
+    strip_prefix = ".",
+)
+
+pkg_tar(
+    name = "deploy_tar",
+    srcs = [
+        ":systemd_files",
+    ],
+    deps = [
+        ":server_files",
+    ],
+)
+
+pkg_deb(
+    name = "frc971-scouting-server",
+    architecture = "amd64",
+    data = ":deploy_tar",
+    description = "The FRC971 scouting web server.",
+    # TODO(phil): What's a good email address for this?
+    maintainer = "frc971@frc971.org",
+    package = "frc971-scouting-server",
+    postinst = "postinst",
+    predepends = [
+        "systemd",
+    ],
+    prerm = "prerm",
+    version = "1",
+)
+
+py_binary(
+    name = "deploy",
+    srcs = [
+        "deploy.py",
+    ],
+    args = [
+        "--deb",
+        "$(location :frc971-scouting-server)",
+    ],
+    data = [
+        ":frc971-scouting-server",
+    ],
+)
diff --git a/scouting/deploy/README.md b/scouting/deploy/README.md
new file mode 100644
index 0000000..6d223da
--- /dev/null
+++ b/scouting/deploy/README.md
@@ -0,0 +1,37 @@
+Deploying the scouting application
+================================================================================
+The scouting application is deployed to `scouting.frc971.org` via `bazel`:
+```console
+$ bazel run //scouting/deploy
+(Reading database ... 119978 files and directories currently installed.)
+Preparing to unpack .../frc971-scouting-server_1_amd64.deb ...
+Removed /etc/systemd/system/multi-user.target.wants/scouting.service.
+Unpacking frc971-scouting-server (1) over (1) ...
+Setting up frc971-scouting-server (1) ...
+Created symlink /etc/systemd/system/multi-user.target.wants/scouting.service → /etc/systemd/system/scouting.service.
+Connection to scouting.frc971.org closed.
+```
+
+You will need SSH access to the scouting server. You can customize the SSH host
+with the `--host` argument.
+
+The Blue Alliance API key
+--------------------------------------------------------------------------------
+You need to set up an API key on the scouting server so that the scraping logic
+can use it. It needs to live in `/var/frc971/scouting/tba_config.json` and look
+as follows:
+```json
+{
+    "api_key": "..."
+}
+```
+
+Starting and stopping the application
+--------------------------------------------------------------------------------
+When you SSH into the scouting server, use `systemctl` to manage
+`scouting.service` like any other service.
+```console
+$ sudo systemctl stop scouting.service
+$ sudo systemctl start scouting.service
+$ sudo systemctl restart scouting.service
+```
diff --git a/scouting/deploy/deploy.py b/scouting/deploy/deploy.py
new file mode 100644
index 0000000..c9886fb
--- /dev/null
+++ b/scouting/deploy/deploy.py
@@ -0,0 +1,34 @@
+import argparse
+from pathlib import Path
+import subprocess
+import sys
+
+def main(argv):
+    """Installs the scouting application on the scouting server."""
+    parser = argparse.ArgumentParser()
+    parser.add_argument(
+        "--deb",
+        type=str,
+        required=True,
+        help="The .deb file to deploy.",
+    )
+    parser.add_argument(
+        "--host",
+        type=str,
+        default="scouting.frc971.org",
+        help="The SSH host to install the scouting web server to.",
+    )
+    args = parser.parse_args(argv[1:])
+    deb = Path(args.deb)
+
+    # Copy the .deb to the scouting server, install it, and delete it again.
+    subprocess.run(["rsync", "-L", args.deb, f"{args.host}:/tmp/{deb.name}"],
+                   check=True, stdin=sys.stdin)
+    subprocess.run(f"ssh -tt {args.host} sudo dpkg -i /tmp/{deb.name}",
+                   shell=True, check=True, stdin=sys.stdin)
+    subprocess.run(f"ssh {args.host} rm -f /tmp/{deb.name}",
+                   shell=True, check=True, stdin=sys.stdin)
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))
diff --git a/scouting/deploy/postinst b/scouting/deploy/postinst
new file mode 100644
index 0000000..a7a8b16
--- /dev/null
+++ b/scouting/deploy/postinst
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+# This script runs after the frc971-scouting-server package is installed. This
+# script is responsible for making sure the webserver has everything it needs,
+# then starts the webserver.
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+# Create a directory for the database to live in.
+mkdir -p /var/frc971/scouting/
+
+# Create an empty The Blue Alliance configuration file.
+if [[ ! -e /var/frc971/scouting/tba_config.json ]]; then
+    echo '{}' > /var/frc971/scouting/tba_config.json
+fi
+
+# Make sure it's all usable by the user.
+chown -R www-data:www-data /var/frc971/scouting/
+
+systemctl daemon-reload
+systemctl enable scouting.service
+systemctl start scouting.service || :
diff --git a/scouting/deploy/prerm b/scouting/deploy/prerm
new file mode 100644
index 0000000..31e3fbc
--- /dev/null
+++ b/scouting/deploy/prerm
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+# This script gets run before the frc971-scouting-server package gets removed
+# or upgraded. This script is responsible for stopping the webserver before the
+# underlying files are removed by dpkg.
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+systemctl stop scouting.service
+systemctl disable scouting.service
+systemctl daemon-reload
diff --git a/scouting/deploy/scouting.service b/scouting/deploy/scouting.service
new file mode 100644
index 0000000..2990f42
--- /dev/null
+++ b/scouting/deploy/scouting.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=FRC971 Scouting Server
+After=systemd-networkd-wait-online.service
+
+[Service]
+User=www-data
+Group=www-data
+Type=simple
+WorkingDirectory=/opt/frc971/scouting_server
+ExecStart=/opt/frc971/scouting_server/scouting/scouting \
+    -port 8080 \
+    -database /var/frc971/scouting/scouting.db \
+    -tba_config /var/frc971/scouting/tba_config.json
+Restart=always
+
+[Install]
+WantedBy=multi-user.target
diff --git a/third_party/rules_pkg/0001-Fix-tree-artifacts.patch b/third_party/rules_pkg/0001-Fix-tree-artifacts.patch
new file mode 100644
index 0000000..567aba7
--- /dev/null
+++ b/third_party/rules_pkg/0001-Fix-tree-artifacts.patch
@@ -0,0 +1,28 @@
+From d654cc64ae71366ea82ac492106e9b2c8fa532d5 Mon Sep 17 00:00:00 2001
+From: Philipp Schrader <philipp.schrader@gmail.com>
+Date: Thu, 10 Mar 2022 23:25:21 -0800
+Subject: [PATCH] Fix tree artifacts
+
+For some reason the upstream code strips the directory names from the
+`babel()` rule that we use. This patch makes it so the directory is
+not stripped.  This makes runfiles layout in the tarball match the
+runfiles layout in `bazel-bin`.
+---
+ pkg/pkg.bzl | 4 ++--
+ 1 file changed, 2 insertions(+), 2 deletions(-)
+
+diff --git a/pkg/pkg.bzl b/pkg/pkg.bzl
+index d7adbbc..a241b26 100644
+--- a/pkg/pkg.bzl
++++ b/pkg/pkg.bzl
+@@ -157,8 +157,8 @@ def _pkg_tar_impl(ctx):
+                     # Tree artifacts need a name, but the name is never really
+                     # the important part. The likely behavior people want is
+                     # just the content, so we strip the directory name.
+-                    dest = "/".join(d_path.split("/")[0:-1])
+-                    add_tree_artifact(content_map, dest, f, src.label)
++                    #dest = "/".join(d_path.split("/")[0:-1])
++                    add_tree_artifact(content_map, d_path, f, src.label)
+                 else:
+                     # Note: This extra remap is the bottleneck preventing this
+                     # large block from being a utility method as shown below.
diff --git a/y2022/control_loops/python/catapult.py b/y2022/control_loops/python/catapult.py
index ad0e25a..2d0588a 100755
--- a/y2022/control_loops/python/catapult.py
+++ b/y2022/control_loops/python/catapult.py
@@ -46,7 +46,7 @@
 
 kCatapultWithBall = catapult_lib.CatapultParams(
     name='Catapult',
-    motor=AddResistance(control_loop.NMotor(control_loop.Falcon(), 2), 0.03),
+    motor=AddResistance(control_loop.NMotor(control_loop.Falcon(), 2), 0.01),
     G=G,
     J=J,
     radius=lever,
diff --git a/y2022/control_loops/superstructure/catapult/catapult.cc b/y2022/control_loops/superstructure/catapult/catapult.cc
index b99f59b..fda62ad 100644
--- a/y2022/control_loops/superstructure/catapult/catapult.cc
+++ b/y2022/control_loops/superstructure/catapult/catapult.cc
@@ -384,15 +384,20 @@
         }
       }
 
-      if (!use_profile_ || catapult_state_ == CatapultState::RESETTING) {
+      if (!use_profile_) {
         catapult_.ForceGoal(catapult_.estimated_position(),
                             catapult_.estimated_velocity());
       }
-    } break;
+    }
+      if (catapult_state_ != CatapultState::RESETTING) {
+        break;
+      } else {
+        [[fallthrough]];
+      }
 
     case CatapultState::RESETTING:
       if (catapult_.controller().R(1, 0) > 0.0) {
-        catapult_.AdjustProfile(7.0, 1000.0);
+        catapult_.AdjustProfile(7.0, 1400.0);
       } else {
         catapult_state_ = CatapultState::PROFILE;
       }
diff --git a/y2022/control_loops/superstructure/superstructure_plotter.ts b/y2022/control_loops/superstructure/superstructure_plotter.ts
index aae3d5a..f8c3a7c 100644
--- a/y2022/control_loops/superstructure/superstructure_plotter.ts
+++ b/y2022/control_loops/superstructure/superstructure_plotter.ts
@@ -78,4 +78,7 @@
   otherPlot.addMessageLine(position, ['flipper_arm_right', 'encoder'])
       .setColor(CYAN)
       .setPointSize(4.0);
+  otherPlot.addMessageLine(output, ['flipper_arms_voltage'])
+      .setColor(BROWN)
+      .setPointSize(4.0);
 }
diff --git a/y2022/joystick_reader.cc b/y2022/joystick_reader.cc
index de37ebe..4b2f082 100644
--- a/y2022/joystick_reader.cc
+++ b/y2022/joystick_reader.cc
@@ -55,12 +55,13 @@
 const ButtonLocation kCatapultPos(4, 3);
 const ButtonLocation kFire(4, 1);
 const ButtonLocation kTurret(4, 15);
+const ButtonLocation kAutoAim(3, 3);
 
 const ButtonLocation kIntakeFrontOut(4, 10);
 const ButtonLocation kIntakeBackOut(4, 9);
 
-const ButtonLocation kRedLocalizerReset(3, 13);
-const ButtonLocation kBlueLocalizerReset(3, 14);
+const ButtonLocation kRedLocalizerReset(4, 13);
+const ButtonLocation kBlueLocalizerReset(4, 14);
 const ButtonLocation kLocalizerReset(3, 8);
 #endif
 
@@ -105,8 +106,8 @@
     frc971::control_loops::drivetrain::LocalizerControl::Builder
         localizer_control_builder = builder.MakeBuilder<
             frc971::control_loops::drivetrain::LocalizerControl>();
-    localizer_control_builder.add_x(-7.4);
-    localizer_control_builder.add_y(1.7);
+    localizer_control_builder.add_x(-7.9);
+    localizer_control_builder.add_y(0.7);
     localizer_control_builder.add_theta_uncertainty(10.0);
     localizer_control_builder.add_theta(M_PI);
     localizer_control_builder.add_keep_current_theta(false);
@@ -285,6 +286,8 @@
           transfer_roller_front_speed);
       superstructure_goal_builder.add_transfer_roller_speed_back(
           transfer_roller_back_speed);
+      superstructure_goal_builder.add_auto_aim(
+          data.IsPressed(kAutoAim));
 
       if (builder.Send(superstructure_goal_builder.Finish()) !=
           aos::RawSender::Error::kOk) {
diff --git a/y2022/y2022_logger.json b/y2022/y2022_logger.json
index 2407b9e..d6fa4a4 100644
--- a/y2022/y2022_logger.json
+++ b/y2022/y2022_logger.json
@@ -35,6 +35,22 @@
       ]
     },
     {
+      "name": "/drivetrain",
+      "type": "frc971.control_loops.drivetrain.Output",
+      "source_node": "roborio",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "logger"
+      ],
+      "destination_nodes": [
+        {
+          "name": "logger",
+          "priority": 2,
+          "time_to_live": 500000000
+        }
+      ]
+    },
+    {
       "name": "/pi1/aos",
       "type": "aos.message_bridge.Timestamp",
       "source_node": "pi1",