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")