Add queueing display.

This commit is contained in:
Patrick Fairbank
2018-09-22 01:10:12 -07:00
parent 2c2d86ea7a
commit 2267259f7c
15 changed files with 404 additions and 19 deletions

View File

@@ -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.

View File

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

View File

@@ -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",
}

View File

@@ -30,10 +30,6 @@ body {
color: #fff;
text-transform: uppercase;
}
#logo {
margin-bottom: 10px;
height: 50px;
}
#standings {
border-radius: 10px;
background-color: #fff;

View File

@@ -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;
}

View File

@@ -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); }
});
});

View File

@@ -86,6 +86,7 @@
<li><a href="/displays/audience">Audience</a></li>
<li><a href="/displays/fta">Field Monitor</a></li>
<li><a href="/displays/pit">Pit</a></li>
<li><a href="/displays/queueing">Queueing</a></li>
<li class="divider"></li>
<li class="dropdown-header">Alliance Station</li>
<li><a href="/displays/alliance_station?station=R1">Red 1</a></li>

View File

@@ -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.
*/}}
<!DOCTYPE html>
<html>
<head>
<title>Queueing Display - {{.EventSettings.Name}} - Cheesy Arena</title>
<link rel="shortcut icon" href="/static/img/favicon.ico">
<link rel="stylesheet" href="/static/css/lib/bootstrap.min.css" />
<link rel="stylesheet" href="/static/css/cheesy-arena.css" />
<link rel="stylesheet" href="/static/css/queueing_display.css" />
</head>
<body>
<div id="header" class="col-lg-10 col-lg-offset-1">
<div class="pull-left">Match Queue</div>
<div class="pull-right">{{.EventSettings.Name}}</div>
</div>
{{range $i, $match := .Matches}}
<div class="col-lg-10 col-lg-offset-1 well">
<div class="col-lg-6">
<div class="row">
<div class="col-lg-5">
<h1>
{{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}}
</h1>
<br />
</div>
<div class="col-lg-2">
<h1>{{$.MatchTypePrefix}}{{$match.DisplayName}}</h1>
</div>
<div class="col-lg-5">
<h1>{{$match.Time.Local.Format "3:04 PM"}}</h1>
</div>
</div>
{{if eq $i 0}}
<div class="row">
<div id="matchState" class="col-lg-5"></div>
<div id="matchTime" class="col-lg-3"></div>
</div>
{{end}}
</div>
<div class="col-lg-1 avatars text-right">
<img class="avatar" src="/static/img/avatars/{{$match.Red1}}.png" /><br />
<img class="avatar" src="/static/img/avatars/{{$match.Red2}}.png" /><br />
<img class="avatar" src="/static/img/avatars/{{$match.Red3}}.png" />
</div>
<div class="col-lg-2 red-teams">
{{$match.Red1}}<br />{{$match.Red2}}<br />{{$match.Red3}}
</div>
<div class="col-lg-2 blue-teams text-right">
{{$match.Blue1}}<br />{{$match.Blue2}}<br />{{$match.Blue3}}
</div>
<div class="col-lg-1 avatars">
<img class="avatar" src="/static/img/avatars/{{$match.Blue1}}.png" /><br />
<img class="avatar" src="/static/img/avatars/{{$match.Blue2}}.png" /><br />
<img class="avatar" src="/static/img/avatars/{{$match.Blue3}}.png" />
</div>
</div>
{{end}}
<div id="footer" class="col-lg-10 col-lg-offset-1">{{.StatusMessage}}</div>
</body>
<script src="/static/js/lib/jquery.min.js"></script>
<script src="/static/js/lib/jquery.json-2.4.min.js"></script>
<script src="/static/js/lib/jquery.websocket-0.0.1.js"></script>
<script src="/static/js/lib/jquery.transit.min.js"></script>
<script src="/static/js/lib/bootstrap.min.js"></script>
<script src="/static/js/cheesy-websocket.js"></script>
<script src="/static/js/match_timing.js"></script>
<script src="/static/js/queueing_display.js"></script>
</html>

View File

@@ -4,7 +4,7 @@
UI for entering realtime scores.
*/}}
{{define "title"}}Scoring{{end}}
{{define "title"}}Scoring Panel{{end}}
{{define "body"}}
<br />
<div class="row">

View File

@@ -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)
}

View File

@@ -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 {

113
web/queueing_display.go Normal file
View File

@@ -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 ""
}
}

View File

@@ -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"
}

View File

@@ -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) {

View File

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