diff --git a/match_play.go b/match_play.go index 4e20978..0cb1578 100644 --- a/match_play.go +++ b/match_play.go @@ -79,7 +79,8 @@ func MatchPlayFakeResultHandler(w http.ResponseWriter, r *http.Request) { handleWebErr(w, fmt.Errorf("Invalid match ID %d.", matchId)) return } - matchResult := MatchResult{MatchId: match.Id} + matchResult := MatchResult{MatchId: match.Id, RedFouls: []Foul{}, BlueFouls: []Foul{Foul{17, "G22", 23.5, true}}, + Cards: Cards{[]int{17}, []int{8}}} matchResult.RedScore = randomScore() matchResult.BlueScore = randomScore() err = CommitMatchScore(match, &matchResult) @@ -93,21 +94,29 @@ func MatchPlayFakeResultHandler(w http.ResponseWriter, r *http.Request) { } func CommitMatchScore(match *Match, matchResult *MatchResult) error { - // Determine the play number for this match. - prevMatchResult, err := db.GetMatchResultForMatch(match.Id) - if err != nil { - return err - } - if prevMatchResult != nil { - matchResult.PlayNumber = prevMatchResult.PlayNumber + 1 - } else { - matchResult.PlayNumber = 1 - } + if matchResult.PlayNumber == 0 { + // Determine the play number for this new match result. + prevMatchResult, err := db.GetMatchResultForMatch(match.Id) + if err != nil { + return err + } + if prevMatchResult != nil { + matchResult.PlayNumber = prevMatchResult.PlayNumber + 1 + } else { + matchResult.PlayNumber = 1 + } - // Save the match result record to the database. - err = db.CreateMatchResult(matchResult) - if err != nil { - return err + // Save the match result record to the database. + err = db.CreateMatchResult(matchResult) + if err != nil { + return err + } + } else { + // We are updating a match result record that already exists. + err := db.SaveMatchResult(matchResult) + if err != nil { + return err + } } // Update and save the match record to the database. @@ -121,7 +130,7 @@ func CommitMatchScore(match *Match, matchResult *MatchResult) error { } else { match.Winner = "T" } - err = db.SaveMatch(match) + err := db.SaveMatch(match) if err != nil { return err } diff --git a/match_review.go b/match_review.go new file mode 100644 index 0000000..d9ec569 --- /dev/null +++ b/match_review.go @@ -0,0 +1,189 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Web routes for editing match results. + +package main + +import ( + "fmt" + "github.com/gorilla/mux" + "net/http" + "strconv" + "text/template" +) + +type MatchReviewListItem struct { + Id int + DisplayName string + Time string + RedTeams []int + BlueTeams []int + RedScore int + BlueScore int + ColorClass string +} + +// Shows the match review interface. +func MatchReviewHandler(w http.ResponseWriter, r *http.Request) { + practiceMatches, err := buildMatchReviewList("practice") + if err != nil { + handleWebErr(w, err) + return + } + qualificationMatches, err := buildMatchReviewList("qualification") + if err != nil { + handleWebErr(w, err) + return + } + eliminationMatches, err := buildMatchReviewList("elimination") + if err != nil { + handleWebErr(w, err) + return + } + + template, err := template.ParseFiles("templates/match_review.html", "templates/base.html") + if err != nil { + handleWebErr(w, err) + return + } + matchesByType := map[string][]MatchReviewListItem{"practice": practiceMatches, + "qualification": qualificationMatches, "elimination": eliminationMatches} + if currentMatchType == "" { + currentMatchType = "practice" + } + data := struct { + *EventSettings + MatchesByType map[string][]MatchReviewListItem + CurrentMatchType string + }{eventSettings, matchesByType, currentMatchType} + err = template.ExecuteTemplate(w, "base", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// Shows the page to edit the results for a match. +func MatchReviewEditGetHandler(w http.ResponseWriter, r *http.Request) { + match, matchResult, err := getMatchResultFromRequest(r) + if err != nil { + handleWebErr(w, err) + return + } + + template, err := template.ParseFiles("templates/edit_match_result.html", "templates/base.html") + if err != nil { + handleWebErr(w, err) + return + } + matchResultJson, err := matchResult.serialize() + if err != nil { + handleWebErr(w, err) + return + } + data := struct { + *EventSettings + Match *Match + MatchResultJson *MatchResultDb + }{eventSettings, match, matchResultJson} + err = template.ExecuteTemplate(w, "base", data) + if err != nil { + handleWebErr(w, err) + return + } +} + +// Updates the results for a match. +func MatchReviewEditPostHandler(w http.ResponseWriter, r *http.Request) { + match, matchResult, err := getMatchResultFromRequest(r) + if err != nil { + handleWebErr(w, err) + return + } + + r.ParseForm() + matchResultJson := MatchResultDb{Id: matchResult.Id, MatchId: match.Id, PlayNumber: matchResult.PlayNumber, + RedScoreJson: r.PostFormValue("redScoreJson"), BlueScoreJson: r.PostFormValue("blueScoreJson"), + RedFoulsJson: r.PostFormValue("redFoulsJson"), BlueFoulsJson: r.PostFormValue("blueFoulsJson"), + CardsJson: r.PostFormValue("cardsJson")} + + // Deserialize the JSON using the same mechanism as to store scoring information in the database. + matchResult, err = matchResultJson.deserialize() + if err != nil { + handleWebErr(w, err) + return + } + + err = CommitMatchScore(match, matchResult) + if err != nil { + handleWebErr(w, err) + return + } + + http.Redirect(w, r, "/match_review", 302) +} + +func getMatchResultFromRequest(r *http.Request) (*Match, *MatchResult, error) { + vars := mux.Vars(r) + matchId, _ := strconv.Atoi(vars["matchId"]) + match, err := db.GetMatchById(matchId) + if err != nil { + return nil, nil, err + } + if match == nil { + return nil, nil, fmt.Errorf("Error: No such match: %d", matchId) + } + matchResult, err := db.GetMatchResultForMatch(matchId) + if err != nil { + return nil, nil, err + } + if matchResult == nil { + // We're scoring a match that hasn't been played yet, but that's okay. + matchResult = new(MatchResult) + } + + return match, matchResult, nil +} + +func buildMatchReviewList(matchType string) ([]MatchReviewListItem, error) { + matches, err := db.GetMatchesByType(matchType) + if err != nil { + return []MatchReviewListItem{}, err + } + + prefix := "" + if matchType == "practice" { + prefix = "P" + } else if matchType == "qualification" { + prefix = "Q" + } + matchReviewList := make([]MatchReviewListItem, len(matches)) + for i, match := range matches { + matchReviewList[i].Id = match.Id + matchReviewList[i].DisplayName = prefix + match.DisplayName + matchReviewList[i].Time = match.Time.Format("Mon 1/02 03:04 PM") + matchReviewList[i].RedTeams = []int{match.Red1, match.Red2, match.Red3} + matchReviewList[i].BlueTeams = []int{match.Blue1, match.Blue2, match.Blue3} + matchResult, err := db.GetMatchResultForMatch(match.Id) + if err != nil { + return []MatchReviewListItem{}, err + } + if matchResult != nil { + matchReviewList[i].RedScore = matchResult.RedScoreSummary().Score + matchReviewList[i].BlueScore = matchResult.BlueScoreSummary().Score + } + switch match.Winner { + case "R": + matchReviewList[i].ColorClass = "danger" + case "B": + matchReviewList[i].ColorClass = "info" + case "T": + matchReviewList[i].ColorClass = "warning" + default: + matchReviewList[i].ColorClass = "" + } + } + + return matchReviewList, nil +} diff --git a/match_review_test.go b/match_review_test.go new file mode 100644 index 0000000..10dbd9f --- /dev/null +++ b/match_review_test.go @@ -0,0 +1,122 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) + +package main + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMatchReview(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + eventSettings, _ = db.GetEventSettings() + + match1 := Match{Type: "practice", DisplayName: "1", Status: "complete", Winner: "R"} + match2 := Match{Type: "practice", DisplayName: "2"} + match3 := Match{Type: "qualification", DisplayName: "1", Status: "complete", Winner: "B"} + match4 := Match{Type: "elimination", DisplayName: "SF1-1", Status: "complete", Winner: "T"} + match5 := Match{Type: "elimination", DisplayName: "SF1-2"} + db.CreateMatch(&match1) + db.CreateMatch(&match2) + db.CreateMatch(&match3) + db.CreateMatch(&match4) + db.CreateMatch(&match5) + + // Check that all matches are listed on the page. + recorder := getHttpResponse("/match_review") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "P1") + assert.Contains(t, recorder.Body.String(), "P2") + assert.Contains(t, recorder.Body.String(), "Q1") + assert.Contains(t, recorder.Body.String(), "SF1-1") + assert.Contains(t, recorder.Body.String(), "SF1-2") +} + +func TestMatchReviewEditExistingResult(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + eventSettings, _ = db.GetEventSettings() + + match := Match{Type: "elimination", DisplayName: "QF4-3", Status: "complete", Winner: "R", Red1: 101, + Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + db.CreateMatch(&match) + matchResult := buildTestMatchResult(match.Id, 1) + db.CreateMatchResult(&matchResult) + + recorder := getHttpResponse("/match_review") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + assert.Contains(t, recorder.Body.String(), "312") // The red score + assert.Contains(t, recorder.Body.String(), "593") // The blue score + + // Check response for non-existent match. + recorder = getHttpResponse(fmt.Sprintf("/match_review/%d/edit", 12345)) + assert.Equal(t, 500, recorder.Code) + assert.Contains(t, recorder.Body.String(), "No such match") + + recorder = getHttpResponse(fmt.Sprintf("/match_review/%d/edit", match.Id)) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + + // Update the score to something else. + postBody := "redScoreJson={\"AutoMobilityBonuses\":3}&blueScoreJson={\"Cycles\":[{\"ScoredHigh\":true}]}&" + + "redFoulsJson=[{\"TeamId\":103,\"IsTechnical\":false}]&blueFoulsJson=[{\"TeamId\":104,\"IsTechnical\":" + + "true}]&cardsJson={\"RedCardTeamIds\":[105]}" + recorder = postHttpResponse(fmt.Sprintf("/match_review/%d/edit", match.Id), postBody) + assert.Equal(t, 302, recorder.Code) + + // Check for the updated scores back on the match list page. + recorder = getHttpResponse("/match_review") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + assert.Contains(t, recorder.Body.String(), "65") // The red score + assert.Contains(t, recorder.Body.String(), "30") // The blue score +} + +func TestMatchReviewCreateNewResult(t *testing.T) { + clearDb() + defer clearDb() + var err error + db, err = OpenDatabase(testDbPath) + assert.Nil(t, err) + defer db.Close() + eventSettings, _ = db.GetEventSettings() + + match := Match{Type: "elimination", DisplayName: "QF4-3", Status: "complete", Winner: "R", Red1: 101, + Red2: 102, Red3: 103, Blue1: 104, Blue2: 105, Blue3: 106} + db.CreateMatch(&match) + + recorder := getHttpResponse("/match_review") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + assert.NotContains(t, recorder.Body.String(), "312") // The red score + assert.NotContains(t, recorder.Body.String(), "593") // The blue score + + recorder = getHttpResponse(fmt.Sprintf("/match_review/%d/edit", match.Id)) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + + // Update the score to something else. + postBody := "redScoreJson={\"AutoHighHot\":4}&blueScoreJson={\"Cycles\":[{\"Assists\":3," + + "\"ScoredLow\":true}]}&redFoulsJson=[]&blueFoulsJson=[]&cardsJson={}" + recorder = postHttpResponse(fmt.Sprintf("/match_review/%d/edit", match.Id), postBody) + assert.Equal(t, 302, recorder.Code) + + // Check for the updated scores back on the match list page. + recorder = getHttpResponse("/match_review") + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), "QF4-3") + assert.Contains(t, recorder.Body.String(), "80") // The red score + assert.Contains(t, recorder.Body.String(), "31") // The blue score +} diff --git a/static/css/cheesy-arena.css b/static/css/cheesy-arena.css new file mode 100644 index 0000000..7b52a0a --- /dev/null +++ b/static/css/cheesy-arena.css @@ -0,0 +1,18 @@ +.red-text { + color: #f00; +} +.blue-text { + color: #00f; +} +.well-red { + background-color: #f2dede; +} +.well-darkred { + background-color: #ebcccc; +} +.well-blue { + background-color: #d9edf7; +} +.well-darkblue { + background-color: #c4e3f3; +} diff --git a/static/js/match_review.js b/static/js/match_review.js new file mode 100644 index 0000000..8f034d5 --- /dev/null +++ b/static/js/match_review.js @@ -0,0 +1,186 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Client-side methods for editing a match in the match review page. + +var scoreTemplate = Handlebars.compile($("#scoreTemplate").html()); +var allianceResults = {}; + +// Hijack the form submission to inject the data in JSON form so that it's easier for the server to parse. +$("form").submit(function() { + updateResults("red"); + updateResults("blue"); + + var redScoreJson = JSON.stringify(allianceResults["red"].score); + var blueScoreJson = JSON.stringify(allianceResults["blue"].score); + var redFoulsJson = JSON.stringify(allianceResults["red"].fouls); + var blueFoulsJson = JSON.stringify(allianceResults["blue"].fouls); + + // Merge the red and blue cards data since that's what the database model expects. + var mergedCards = {YellowCardTeamIds: allianceResults["red"].cards.YellowCardTeamIds. + concat(allianceResults["blue"].cards.YellowCardTeamIds), + RedCardTeamIds:allianceResults["red"].cards.RedCardTeamIds. + concat(allianceResults["blue"].cards.RedCardTeamIds)}; + var cardsJson = JSON.stringify(mergedCards); + + // Inject the JSON data into the form as hidden inputs. + $("").attr("type", "hidden").attr("name", "redScoreJson").attr("value", redScoreJson).appendTo("form"); + $("").attr("type", "hidden").attr("name", "blueScoreJson").attr("value", blueScoreJson).appendTo("form"); + $("").attr("type", "hidden").attr("name", "redFoulsJson").attr("value", redFoulsJson).appendTo("form"); + $("").attr("type", "hidden").attr("name", "blueFoulsJson").attr("value", blueFoulsJson).appendTo("form"); + $("").attr("type", "hidden").attr("name", "cardsJson").attr("value", cardsJson).appendTo("form"); + + return true; +}); + +// Draws the match-editing form for one alliance based on the cached result data. +var renderResults = function(alliance) { + var result = allianceResults[alliance]; + var scoreContent = scoreTemplate(result); + $("#" + alliance + "Score").html(scoreContent); + + // Set the values of the form fields from the JSON results data. + $("select[name=" + alliance + "AutoMobilityBonuses]").val(result.score.AutoMobilityBonuses); + $("input[name=" + alliance + "AutoHighHot]").val(result.score.AutoHighHot); + $("input[name=" + alliance + "AutoHigh]").val(result.score.AutoHigh); + $("input[name=" + alliance + "AutoLowHot]").val(result.score.AutoLowHot); + $("input[name=" + alliance + "AutoLow]").val(result.score.AutoLow); + $("input[name=" + alliance + "AutoClearHigh]").val(result.score.AutoClearHigh); + $("input[name=" + alliance + "AutoClearLow]").val(result.score.AutoClearLow); + + $.each(result.score.Cycles, function(k, v) { + $("#" + alliance + "Cycle" + k + "Title").text("Cycle " + (k + 1)); + $("input[name=" + alliance + "Cycle" + k + "Assists][value=" + v.Assists + "]").prop("checked", true); + + var trussCatch; + if (v.Truss && v.Catch) { + trussCatch = "TC"; + } else if (v.Truss) { + trussCatch = "T"; + } else { + trussCatch = "N"; + } + $("input[name=" + alliance + "Cycle" + k + "TrussCatch][value=" + trussCatch + "]").prop("checked", true); + + var cycleEnd; + if (v.ScoredHigh) { + cycleEnd = "SH"; + } else if (v.ScoredLow) { + cycleEnd = "SL"; + } else if (v.DeadBall) { + cycleEnd = "DB"; + } else { + cycleEnd = "DE"; + } + $("input[name=" + alliance + "Cycle" + k + "End][value=" + cycleEnd + "]").prop("checked", true); + }); + + $.each(result.fouls, function(k, v) { + $("input[name=" + alliance + "Foul" + k + "Team][value=" + v.TeamId + "]").prop("checked", true); + $("input[name=" + alliance + "Foul" + k + "Tech][value=" + v.IsTechnical + "]").prop("checked", true); + $("input[name=" + alliance + "Foul" + k + "Rule]").val(v.Rule); + $("input[name=" + alliance + "Foul" + k + "Time]").val(v.TimeInMatchSec); + }); + + $.each(result.cards.YellowCardTeamIds, function(k, v) { + $("input[name=" + alliance + "Team" + v + "Card][value=Y]").prop("checked", true); + }); + $.each(result.cards.RedCardTeamIds, function(k, v) { + $("input[name=" + alliance + "Team" + v + "Card][value=R]").prop("checked", true); + }); +} + +// Converts the current form values back into JSON structures and caches them. +var updateResults = function(alliance) { + var result = allianceResults[alliance]; + var formData = {} + $.each($("form").serializeArray(), function(k, v) { + formData[v.name] = v.value; + }); + + result.score.AutoMobilityBonuses = parseInt(formData[alliance + "AutoMobilityBonuses"]); + result.score.AutoHighHot = parseInt(formData[alliance + "AutoHighHot"]); + result.score.AutoHigh = parseInt(formData[alliance + "AutoHigh"]); + result.score.AutoLowHot = parseInt(formData[alliance + "AutoLowHot"]); + result.score.AutoLow = parseInt(formData[alliance + "AutoLow"]); + result.score.AutoClearHigh = parseInt(formData[alliance + "AutoClearHigh"]); + result.score.AutoClearLow = parseInt(formData[alliance + "AutoClearLow"]); + + result.score.Cycles = []; + for (var i = 0; formData[alliance + "Cycle" + i + "Assists"]; i++) { + var prefix = alliance + "Cycle" + i; + var cycle = {Assists: parseInt(formData[prefix + "Assists"]), Truss: false, Catch: false, + ScoredHigh: false, ScoredLow: false, DeadBall: false} + switch (formData[prefix + "TrussCatch"]) { + case "TC": + cycle.Catch = true; + case "T": + cycle.Truss = true; + } + switch (formData[prefix + "End"]) { + case "SH": + cycle.ScoredHigh = true; + break; + case "SL": + cycle.ScoredLow = true; + break; + case "DB": + cycle.DeadBall = true; + } + result.score.Cycles.push(cycle); + } + + result.fouls = []; + for (var i = 0; formData[alliance + "Foul" + i + "Tech"]; i++) { + var prefix = alliance + "Foul" + i; + var foul = {TeamId: parseInt(formData[prefix + "Team"]), Rule: formData[prefix + "Rule"], + TimeInMatchSec: parseFloat(formData[prefix + "Time"]), + IsTechnical: (formData[prefix + "Tech"] == "true")}; + result.fouls.push(foul); + } + + result.cards.YellowCardTeamIds = [] + result.cards.RedCardTeamIds = [] + $.each([result.team1, result.team2, result.team3], function(i, team) { + switch (formData[alliance + "Team" + team + "Card"]) { + case "Y": + result.cards.YellowCardTeamIds.push(team); + break + case "R": + result.cards.RedCardTeamIds.push(team); + } + }); +} + +// Appends a blank cycle to the end of the list. +var addCycle = function(alliance) { + updateResults(alliance); + var result = allianceResults[alliance]; + result.score.Cycles.push({Assists: 1, Truss: false, Catch: false, ScoredHigh: false, ScoredLow: false, + DeadBall: false}) + renderResults(alliance); +} + +// Removes the given cycle from the list. +var deleteCycle = function(alliance, index) { + updateResults(alliance); + var result = allianceResults[alliance]; + result.score.Cycles.splice(index, 1); + renderResults(alliance); +} + +// Appends a blank foul to the end of the list. +var addFoul = function(alliance) { + updateResults(alliance); + var result = allianceResults[alliance]; + result.fouls.push({TeamId: 0, Rule: "", TimeInMatchSec: 0, IsTechnical: false}) + renderResults(alliance); +} + +// Removes the given foul from the list. +var deleteFoul = function(alliance, index) { + updateResults(alliance); + var result = allianceResults[alliance]; + result.fouls.splice(index, 1); + renderResults(alliance); +} diff --git a/templates/base.html b/templates/base.html index 930ca26..660e2a4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,6 +7,7 @@ +