diff --git a/field/arena.go b/field/arena.go
index ad55c46..3c7f468 100644
--- a/field/arena.go
+++ b/field/arena.go
@@ -265,14 +265,15 @@ func (arena *Arena) LoadNextMatch() error {
}
for _, match := range matches {
if match.Status != "complete" {
- err = arena.LoadMatch(&match)
- if err != nil {
+ if err = arena.LoadMatch(&match); err != nil {
return err
}
- break
+ return nil
}
}
- return nil
+
+ // There are no matches left in the series; just load a test match.
+ return arena.LoadTestMatch()
}
// Assigns the given team to the given station, also substituting it into the match record.
diff --git a/field/arena_test.go b/field/arena_test.go
index 69310c3..e41bf15 100644
--- a/field/arena_test.go
+++ b/field/arena_test.go
@@ -412,8 +412,8 @@ func TestLoadNextMatch(t *testing.T) {
arena.Database.SaveMatch(&practiceMatch3)
err = arena.LoadNextMatch()
assert.Nil(t, err)
- assert.Equal(t, practiceMatch3.Id, arena.CurrentMatch.Id)
- assert.Equal(t, "complete", practiceMatch3.Status)
+ assert.Equal(t, 0, arena.CurrentMatch.Id)
+ assert.Equal(t, "test", arena.CurrentMatch.Type)
err = arena.LoadMatch(&qualificationMatch1)
assert.Nil(t, err)
diff --git a/field/display.go b/field/display.go
index 68e244a..8429b16 100644
--- a/field/display.go
+++ b/field/display.go
@@ -31,6 +31,7 @@ const (
AudienceDisplay
FieldMonitorDisplay
PitDisplay
+ QueueingDisplay
TwitchStreamDisplay
)
@@ -41,6 +42,7 @@ var DisplayTypeNames = map[DisplayType]string{
AudienceDisplay: "Audience",
FieldMonitorDisplay: "Field Monitor",
PitDisplay: "Pit",
+ QueueingDisplay: "Queueing",
TwitchStreamDisplay: "Twitch Stream",
}
@@ -51,6 +53,7 @@ var displayTypePaths = map[DisplayType]string{
AudienceDisplay: "/displays/audience",
FieldMonitorDisplay: "/displays/fta",
PitDisplay: "/displays/pit",
+ QueueingDisplay: "/displays/queueing",
TwitchStreamDisplay: "/displays/twitch",
}
diff --git a/static/css/pit_display.css b/static/css/pit_display.css
index 1fb56a3..444f742 100644
--- a/static/css/pit_display.css
+++ b/static/css/pit_display.css
@@ -30,10 +30,6 @@ body {
color: #fff;
text-transform: uppercase;
}
-#logo {
- margin-bottom: 10px;
- height: 50px;
-}
#standings {
border-radius: 10px;
background-color: #fff;
diff --git a/static/css/queueing_display.css b/static/css/queueing_display.css
new file mode 100644
index 0000000..6a4cfa1
--- /dev/null
+++ b/static/css/queueing_display.css
@@ -0,0 +1,63 @@
+/*
+ Copyright 2018 Team 254. All Rights Reserved.
+ Author: pat@patfairbank.com (Patrick Fairbank)
+*/
+
+html {
+ height: 100%;
+ cursor: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ overflow: hidden;
+}
+body {
+ height: 100%;
+ background: -moz-linear-gradient(top, #003375 1%, #3C679D 100%); /* FF3.6+ */
+ background: -webkit-linear-gradient(top, #003375 1%, #3C679D 100%); /* Chrome10+,Safari5.1+ */
+ background-repeat: no-repeat;
+}
+h1 {
+ font-size: 40px;
+}
+#header {
+ padding: 10px 0px;
+ font-size: 40px;
+ font-family: "FuturaLTBold";
+ color: #fff;
+ text-transform: uppercase;
+}
+.well {
+ background-color: #ccc;
+ border: 1px solid #333;
+ padding: 10px;
+ margin-top: 0px;
+ margin-bottom: 15px;
+}
+#matchState, #matchTime {
+ font-size: 35px;
+ color: #666;
+}
+#matchTime {
+ font-weight: bold;
+}
+.red-teams, .blue-teams {
+ font-family: FuturaLTBold;
+ line-height: 51px;
+ font-size: 45px;
+}
+.red-teams {
+ color: #ff4444;
+}
+.blue-teams {
+ color: #2080ff;
+}
+.avatars {
+ line-height: 51px;
+}
+#footer {
+ font-size: 25px;
+ font-family: "FuturaLTBold";
+ color: #fff;
+ text-align: center;
+ text-transform: uppercase;
+}
diff --git a/static/js/queueing_display.js b/static/js/queueing_display.js
new file mode 100644
index 0000000..a085b50
--- /dev/null
+++ b/static/js/queueing_display.js
@@ -0,0 +1,41 @@
+// Copyright 2018 Team 254. All Rights Reserved.
+// Author: pat@patfairbank.com (Patrick Fairbank)
+//
+// Client-side logic for the queueing display.
+
+var websocket;
+var firstMatchLoad = true;
+
+// Handles a websocket message to update the teams for the current match.
+var handleMatchLoad = function(data) {
+ // Since the server always sends a matchLoad message upon establishing the websocket connection, ignore the first one.
+ if (!firstMatchLoad) {
+ location.reload();
+ }
+ firstMatchLoad = false;
+};
+
+// Handles a websocket message to update the match time countdown.
+var handleMatchTime = function(data) {
+ translateMatchTime(data, function(matchState, matchStateText, countdownSec) {
+ $("#matchState").text(matchStateText);
+ var countdownString = String(countdownSec % 60);
+ if (countdownString.length === 1) {
+ countdownString = "0" + countdownString;
+ }
+ countdownString = Math.floor(countdownSec / 60) + ":" + countdownString;
+ $("#matchTime").text(countdownString);
+ });
+};
+
+$(function() {
+ // Fall back to a blank avatar if one doesn't exist for the team.
+ $(".avatar").attr("onerror", "this.src='/static/img/avatars/0.png';");
+
+ // Set up the websocket back to the server.
+ websocket = new CheesyWebsocket("/displays/queueing/websocket", {
+ matchLoad: function(event) { handleMatchLoad(event.data); },
+ matchTime: function(event) { handleMatchTime(event.data); },
+ matchTiming: function(event) { handleMatchTiming(event.data); }
+ });
+});
diff --git a/templates/base.html b/templates/base.html
index 267f02a..ce3237d 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -86,6 +86,7 @@
Audience
Field Monitor
Pit
+ Queueing
Red 1
diff --git a/templates/queueing_display.html b/templates/queueing_display.html
new file mode 100644
index 0000000..6efda1f
--- /dev/null
+++ b/templates/queueing_display.html
@@ -0,0 +1,83 @@
+{{/*
+ Copyright 2018 Team 254. All Rights Reserved.
+ Author: pat@patfairbank.com (Patrick Fairbank)
+
+ Display that shows upcoming matches and timing information.
+*/}}
+
+
+
+ Queueing Display - {{.EventSettings.Name}} - Cheesy Arena
+
+
+
+
+
+
+
+ {{range $i, $match := .Matches}}
+
+
+
+
+
+ {{if eq $i 0}}
+ On Field
+ {{else if eq $i 1}}
+ On Deck
+ {{else if eq $i 2}}
+ Last Call
+ {{else if eq $i 3}}
+ Second Call
+ {{else if eq $i 4}}
+ First Call
+ {{end}}
+
+
+
+
+
{{$.MatchTypePrefix}}{{$match.DisplayName}}
+
+
+
{{$match.Time.Local.Format "3:04 PM"}}
+
+
+ {{if eq $i 0}}
+
+ {{end}}
+
+
+
+ {{$match.Red1}} {{$match.Red2}} {{$match.Red3}}
+
+
+ {{$match.Blue1}} {{$match.Blue2}} {{$match.Blue3}}
+
+
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/scoring_panel.html b/templates/scoring_panel.html
index 4c18c9a..972731a 100644
--- a/templates/scoring_panel.html
+++ b/templates/scoring_panel.html
@@ -4,7 +4,7 @@
UI for entering realtime scores.
*/}}
-{{define "title"}}Scoring{{end}}
+{{define "title"}}Scoring Panel{{end}}
{{define "body"}}
diff --git a/web/match_play.go b/web/match_play.go
index 13ad46b..a3aa470 100644
--- a/web/match_play.go
+++ b/web/match_play.go
@@ -30,9 +30,6 @@ type MatchPlayListItem struct {
type MatchPlayList []MatchPlayListItem
-// Global var to hold the current active tournament so that its matches are displayed by default.
-var currentMatchType string
-
// Shows the match play control interface.
func (web *Web) matchPlayHandler(w http.ResponseWriter, r *http.Request) {
if !web.userIsAdmin(w, r) {
@@ -62,7 +59,8 @@ func (web *Web) matchPlayHandler(w http.ResponseWriter, r *http.Request) {
}
matchesByType := map[string]MatchPlayList{"practice": practiceMatches,
"qualification": qualificationMatches, "elimination": eliminationMatches}
- if currentMatchType == "" {
+ currentMatchType := web.arena.CurrentMatch.Type
+ if currentMatchType == "test" {
currentMatchType = "practice"
}
allowSubstitution := web.arena.CurrentMatch.Type != "qualification"
@@ -79,7 +77,8 @@ func (web *Web) matchPlayHandler(w http.ResponseWriter, r *http.Request) {
Match *model.Match
AllowSubstitution bool
IsReplay bool
- }{web.arena.EventSettings, matchesByType, currentMatchType, web.arena.CurrentMatch, allowSubstitution, isReplay}
+ }{web.arena.EventSettings, matchesByType, currentMatchType, web.arena.CurrentMatch, allowSubstitution,
+ isReplay}
err = template.ExecuteTemplate(w, "base", data)
if err != nil {
handleWebErr(w, err)
@@ -115,7 +114,6 @@ func (web *Web) matchPlayLoadHandler(w http.ResponseWriter, r *http.Request) {
handleWebErr(w, err)
return
}
- currentMatchType = web.arena.CurrentMatch.Type
http.Redirect(w, r, "/match_play", 303)
}
diff --git a/web/match_review.go b/web/match_review.go
index f4c63b6..934a34f 100644
--- a/web/match_review.go
+++ b/web/match_review.go
@@ -53,7 +53,8 @@ func (web *Web) matchReviewHandler(w http.ResponseWriter, r *http.Request) {
}
matchesByType := map[string][]MatchReviewListItem{"practice": practiceMatches,
"qualification": qualificationMatches, "elimination": eliminationMatches}
- if currentMatchType == "" {
+ currentMatchType := web.arena.CurrentMatch.Type
+ if currentMatchType == "test" {
currentMatchType = "practice"
}
data := struct {
diff --git a/web/queueing_display.go b/web/queueing_display.go
new file mode 100644
index 0000000..f402314
--- /dev/null
+++ b/web/queueing_display.go
@@ -0,0 +1,113 @@
+// Copyright 2018 Team 254. All Rights Reserved.
+// Author: pat@patfairbank.com (Patrick Fairbank)
+//
+// Web handlers for queueing display.
+
+package web
+
+import (
+ "fmt"
+ "github.com/Team254/cheesy-arena/model"
+ "github.com/Team254/cheesy-arena/websocket"
+ "net/http"
+)
+
+const numMatchesToShow = 5
+
+// Renders the queueing display that shows upcoming matches and timing information.
+func (web *Web) queueingDisplayHandler(w http.ResponseWriter, r *http.Request) {
+ if !web.userIsReader(w, r) {
+ return
+ }
+
+ if !web.enforceDisplayConfiguration(w, r, nil) {
+ return
+ }
+
+ matchTypePrefix := ""
+ currentMatchType := web.arena.CurrentMatch.Type
+ if currentMatchType == "practice" {
+ matchTypePrefix = "P"
+ } else if currentMatchType == "qualification" {
+ matchTypePrefix = "Q"
+ }
+ matches, err := web.arena.Database.GetMatchesByType(currentMatchType)
+ if err != nil {
+ handleWebErr(w, err)
+ return
+ }
+ var upcomingMatches []model.Match
+ for _, match := range matches {
+ if match.Status == "complete" {
+ continue
+ }
+ upcomingMatches = append(upcomingMatches, match)
+ if len(upcomingMatches) == numMatchesToShow {
+ break
+ }
+ }
+
+ template, err := web.parseFiles("templates/queueing_display.html")
+ if err != nil {
+ handleWebErr(w, err)
+ return
+ }
+
+ data := struct {
+ *model.EventSettings
+ MatchTypePrefix string
+ Matches []model.Match
+ StatusMessage string
+ }{web.arena.EventSettings, matchTypePrefix, upcomingMatches, generateEventStatusMessage(matches)}
+ err = template.ExecuteTemplate(w, "queueing_display.html", data)
+ if err != nil {
+ handleWebErr(w, err)
+ return
+ }
+}
+
+// The websocket endpoint for the queueing display to receive updates.
+func (web *Web) queueingDisplayWebsocketHandler(w http.ResponseWriter, r *http.Request) {
+ if !web.userIsReader(w, r) {
+ return
+ }
+
+ display, err := web.registerDisplay(r)
+ if err != nil {
+ handleWebErr(w, err)
+ return
+ }
+ defer web.arena.MarkDisplayDisconnected(display)
+
+ ws, err := websocket.NewWebsocket(w, r)
+ if err != nil {
+ handleWebErr(w, err)
+ return
+ }
+ defer ws.Close()
+
+ // Subscribe the websocket to the notifiers whose messages will be passed on to the client.
+ ws.HandleNotifiers(web.arena.MatchTimingNotifier, web.arena.MatchLoadNotifier, web.arena.MatchTimeNotifier,
+ web.arena.DisplayConfigurationNotifier, web.arena.ReloadDisplaysNotifier)
+}
+
+// Returns a message indicating how early or late the event is running.
+func generateEventStatusMessage(matches []model.Match) string {
+ for i := len(matches) - 1; i >= 0; i-- {
+ match := matches[i]
+ if match.Status == "complete" {
+ minutesLate := match.StartedAt.Sub(match.Time).Minutes()
+ if minutesLate > 2 {
+ return fmt.Sprintf("Event is running %d minutes late", int(minutesLate))
+ } else if minutesLate < -2 {
+ return fmt.Sprintf("Event is running %d minutes early", int(-minutesLate))
+ }
+ }
+ }
+
+ if len(matches) > 0 {
+ return "Event is running on schedule"
+ } else {
+ return ""
+ }
+}
diff --git a/web/queueing_display_test.go b/web/queueing_display_test.go
new file mode 100644
index 0000000..4d17a8a
--- /dev/null
+++ b/web/queueing_display_test.go
@@ -0,0 +1,83 @@
+// Copyright 2014 Team 254. All Rights Reserved.
+// Author: pat@patfairbank.com (Patrick Fairbank)
+
+package web
+
+import (
+ "github.com/Team254/cheesy-arena/model"
+ "github.com/Team254/cheesy-arena/websocket"
+ gorillawebsocket "github.com/gorilla/websocket"
+ "github.com/stretchr/testify/assert"
+ "testing"
+ "time"
+)
+
+func TestQueueingDisplay(t *testing.T) {
+ web := setupTestWeb(t)
+
+ recorder := web.getHttpResponse("/displays/queueing?displayId=1")
+ assert.Equal(t, 200, recorder.Code)
+ assert.Contains(t, recorder.Body.String(), "Queueing Display - Untitled Event - Cheesy Arena")
+}
+
+func TestQueueingDisplayWebsocket(t *testing.T) {
+ web := setupTestWeb(t)
+
+ server, wsUrl := web.startTestServer()
+ defer server.Close()
+ conn, _, err := gorillawebsocket.DefaultDialer.Dial(wsUrl+"/displays/queueing/websocket?displayId=1", nil)
+ assert.Nil(t, err)
+ defer conn.Close()
+ ws := websocket.NewTestWebsocket(conn)
+
+ // Should get a few status updates right after connection.
+ readWebsocketType(t, ws, "matchTiming")
+ readWebsocketType(t, ws, "matchLoad")
+ readWebsocketType(t, ws, "matchTime")
+ readWebsocketType(t, ws, "displayConfiguration")
+}
+
+func TestQueueingStatusMessage(t *testing.T) {
+ assert.Equal(t, "", generateEventStatusMessage([]model.Match{}))
+
+ matches := make([]model.Match, 3)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+
+ // Check within threshold considered to be on time.
+ setMatchLateness(&matches[1], 0)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], 60)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], -60)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], 90)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], -90)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], 110)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], -110)
+ assert.Equal(t, "Event is running on schedule", generateEventStatusMessage(matches))
+
+ // Check lateness.
+ setMatchLateness(&matches[1], 130)
+ assert.Equal(t, "Event is running 2 minutes late", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], 3601)
+ assert.Equal(t, "Event is running 60 minutes late", generateEventStatusMessage(matches))
+
+ // Check earliness.
+ setMatchLateness(&matches[1], -130)
+ assert.Equal(t, "Event is running 2 minutes early", generateEventStatusMessage(matches))
+ setMatchLateness(&matches[1], -3601)
+ assert.Equal(t, "Event is running 60 minutes early", generateEventStatusMessage(matches))
+
+ // Check that later matches supersede earlier ones.
+ setMatchLateness(&matches[2], 180)
+ assert.Equal(t, "Event is running 3 minutes late", generateEventStatusMessage(matches))
+}
+
+func setMatchLateness(match *model.Match, secondsLate int) {
+ match.Time = time.Now()
+ match.StartedAt = time.Now().Add(time.Second * time.Duration(secondsLate))
+ match.Status = "complete"
+}
diff --git a/web/scoring_panel_test.go b/web/scoring_panel_test.go
index 9d969ae..31ed1f6 100644
--- a/web/scoring_panel_test.go
+++ b/web/scoring_panel_test.go
@@ -22,7 +22,7 @@ func TestScoringPanel(t *testing.T) {
assert.Equal(t, 200, recorder.Code)
recorder = web.getHttpResponse("/panels/scoring/blue")
assert.Equal(t, 200, recorder.Code)
- assert.Contains(t, recorder.Body.String(), "Scoring - Untitled Event - Cheesy Arena")
+ assert.Contains(t, recorder.Body.String(), "Scoring Panel - Untitled Event - Cheesy Arena")
}
func TestScoringPanelWebsocket(t *testing.T) {
diff --git a/web/web.go b/web/web.go
index d1b4f66..1585a2d 100644
--- a/web/web.go
+++ b/web/web.go
@@ -146,6 +146,8 @@ func (web *Web) newHandler() http.Handler {
router.HandleFunc("/displays/fta/websocket", web.ftaDisplayWebsocketHandler).Methods("GET")
router.HandleFunc("/displays/pit", web.pitDisplayHandler).Methods("GET")
router.HandleFunc("/displays/pit/websocket", web.pitDisplayWebsocketHandler).Methods("GET")
+ router.HandleFunc("/displays/queueing", web.queueingDisplayHandler).Methods("GET")
+ router.HandleFunc("/displays/queueing/websocket", web.queueingDisplayWebsocketHandler).Methods("GET")
router.HandleFunc("/displays/twitch", web.twitchDisplayHandler).Methods("GET")
router.HandleFunc("/displays/twitch/websocket", web.twitchDisplayWebsocketHandler).Methods("GET")
router.HandleFunc("/login", web.loginHandler).Methods("GET")