From b7ee41318d08e65a302418ccbc242483feec4701 Mon Sep 17 00:00:00 2001 From: Joseph Perrino <107595901+sei-jperrino@users.noreply.github.com> Date: Tue, 13 Sep 2022 15:13:52 -0400 Subject: [PATCH] Miscellaneous admin and support improvements (#53) * Support can now view challenges * Support can now observe challenges * Number of Challenges Deployed added to participation reports - All participation reports now show the number of challenges deployed across their specific type - Empty/null string correction added to all participation reports for more accuracy * Number of teams and teams with sessions added to participation reports - Participation reports now track the number of teams in in each participation category and the number of teams with sessions * Naming change in ReportService.cs * Ticket support intervals made more flexible via config - Ticket support intervals can be configured in AppSettings.cs - To add an interval, add a new line to ShiftStrings - Ticket support exports now dynamically adapt to the number of intervals and are not locked to specific shifts * Board counting made more accurate - Single-sponsor teams counted more accurately - Teams of one or teams with only one sponsor included in each sponsor row of the sponsor report - Multisponsor team counts shown on board * Shortened label for timezone in support reports * Shifts can now be adjusted within appsettings.conf * Minor comment correction --- .../Extensions/DefaultsStartupExtensions.cs | 14 +- .../Features/Challenge/ChallengeController.cs | 16 +- .../Features/Report/ReportController.cs | 82 +++++++-- .../Features/Report/ReportModels.cs | 3 + .../Features/Report/ReportService.cs | 169 +++++++++++++----- src/Gameboard.Api/Features/Ticket/Ticket.cs | 10 +- src/Gameboard.Api/Structure/AppSettings.cs | 31 ++++ src/Gameboard.Api/appsettings.conf | 12 ++ 8 files changed, 264 insertions(+), 73 deletions(-) diff --git a/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs b/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs index 2fd608f9..306556df 100644 --- a/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DefaultsStartupExtensions.cs @@ -28,11 +28,23 @@ string contentRootPath if (File.Exists(certificateFile)) certificateTemplate = File.ReadAllText(certificateFile); + var shiftTimezone = defaults.ShiftTimezone.NotEmpty() ? defaults.ShiftTimezone : Defaults.ShiftTimezoneFallback; + var shiftStrings = defaults.ShiftStrings != null ? defaults.ShiftStrings : Defaults.ShiftStringsFallback; + var shifts = defaults.ShiftStrings != null ? new System.DateTimeOffset[shiftStrings.Length][] : Defaults.ShiftsFallback; + if (defaults.ShiftStrings != null) { + for (int i = 0; i < shiftStrings.Length; i++) { + shifts[i] = new System.DateTimeOffset[] { Defaults.ConvertTime(shiftStrings[i][0], shiftTimezone), Defaults.ConvertTime(shiftStrings[i][1], shiftTimezone) }; + } + } + return new Defaults { FeedbackTemplate = feedbackTemplate, FeedbackTemplateFile = defaults.FeedbackTemplateFile, CertificateTemplate = certificateTemplate, - CertificateTemplateFile = defaults.CertificateTemplateFile + CertificateTemplateFile = defaults.CertificateTemplateFile, + ShiftStrings = shiftStrings, + Shifts = shifts, + ShiftTimezone = shiftTimezone }; }); diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index 9ebfbf88..8d3737e4 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -280,6 +280,7 @@ public async Task GetConsole([FromBody]ConsoleRequest model) AuthorizeAny( () => Actor.IsDirector, () => Actor.IsObserver, + () => Actor.IsSupport, () => isTeamMember ); @@ -318,7 +319,8 @@ public async Task> FindConsoles([FromQuery]string gid) { AuthorizeAny( () => Actor.IsDirector, - () => Actor.IsObserver + () => Actor.IsObserver, + () => Actor.IsSupport ); return await ChallengeService.GetChallengeConsoles(gid); } @@ -329,7 +331,8 @@ public ConsoleActor[] GetConsoleActors([FromQuery]string gid) { AuthorizeAny( () => Actor.IsDirector, - () => Actor.IsObserver + () => Actor.IsObserver, + () => Actor.IsSupport ); return ChallengeService.GetConsoleActors(gid); } @@ -340,7 +343,8 @@ public ConsoleActor GetConsoleActor([FromQuery]string uid) { AuthorizeAny( () => Actor.IsDirector, - () => Actor.IsObserver + () => Actor.IsObserver, + () => Actor.IsSupport ); return ChallengeService.GetConsoleActor(uid); } @@ -355,7 +359,8 @@ public ConsoleActor GetConsoleActor([FromQuery]string uid) public async Task List([FromQuery] SearchFilter model) { AuthorizeAny( - () => Actor.IsDirector + () => Actor.IsDirector, + () => Actor.IsSupport ); return await ChallengeService.List(model); @@ -388,7 +393,8 @@ public async Task ListByUser([FromQuery] ChallengeSearchFil public async Task ListArchived([FromQuery] SearchFilter model) { AuthorizeAny( - () => Actor.IsDirector + () => Actor.IsDirector, + () => Actor.IsSupport ); return await ChallengeService.ListArchived(model); diff --git a/src/Gameboard.Api/Features/Report/ReportController.cs b/src/Gameboard.Api/Features/Report/ReportController.cs index df890bd0..fbef1713 100644 --- a/src/Gameboard.Api/Features/Report/ReportController.cs +++ b/src/Gameboard.Api/Features/Report/ReportController.cs @@ -24,7 +24,8 @@ public ReportController( GameService gameService, ChallengeSpecService challengeSpecService, FeedbackService feedbackService, - TicketService ticketService + TicketService ticketService, + Defaults defaults ): base(logger, cache) { Service = service; @@ -32,6 +33,7 @@ TicketService ticketService FeedbackService = feedbackService; ChallengeSpecService = challengeSpecService; TicketService = ticketService; + Defaults = defaults; } ReportService Service { get; } @@ -39,6 +41,7 @@ TicketService ticketService FeedbackService FeedbackService { get; } ChallengeSpecService ChallengeSpecService { get; } TicketService TicketService { get; } + Defaults Defaults { get; } [HttpGet("/api/report/userstats")] [Authorize] @@ -485,7 +488,7 @@ public async Task> GetFeedbackStats([FromQuery] Feed #region Support Stats [HttpGet("/api/report/supportdaystats")] [Authorize] - public async Task> GetTicketVolumeStats([FromQuery] TicketReportFilter model) + public async Task> GetTicketVolumeStats([FromQuery] TicketReportFilter model) { AuthorizeAny( () => Actor.IsObserver @@ -605,23 +608,64 @@ public async Task ExportTicketDayStats([FromQuery] TicketReportFi var result = await Service.GetTicketVolume(model); - List> dayStats = new List>(); - dayStats.Add(new Tuple("Date", "Day of Week", "Shift 1 Count", "Shift 2 Count", "Outside of Shifts Count", "Total Created")); - - int[] sums = new int[4]; - - foreach (TicketDayGroup group in result) - { - dayStats.Add(new Tuple(group.Date, group.DayOfWeek, group.Shift1Count.ToString(), group.Shift2Count.ToString(), group.OutsideShiftCount.ToString(), group.Count.ToString())); - sums[0] += group.Shift1Count; - sums[1] += group.Shift2Count; - sums[2] += group.OutsideShiftCount; - sums[3] += group.Count; + // Create the file result early on so we can manipulate its bits later + FileContentResult f = File( + Service.ConvertToBytes(""), + "application/octet-stream", + string.Format("ticket-day-stats-{0}", DateTime.UtcNow.ToString("yyyy-MM-dd")) + ".csv"); + // Send the file contents to a list so it can be added to easily + List fc = f.FileContents.ToList(); + + // Create an array of titles for the first line of the CSV + string[] titles = new string[Defaults.ShiftStrings.Length + 4]; + titles[0] = "Date"; + titles[1] = "Day of Week"; + titles[titles.Count() - 2] = "Outside of Shifts Count"; + titles[titles.Count() - 1] = "Total Created"; + // Create a new title for each shift + for (int i = 2; i < titles.Count() - 2; i++) { + titles[i] = "Shift " + (i - 1) + " Count"; + } + // Add to the byte list and remove the whitespace and newline from the file + fc.AddRange(Service.ConvertToBytes(titles)); + fc = fc.TakeLast(fc.Count() - 2).ToList(); + + // Create an array of sums + int[] sums = new int[titles.Count() - 2]; + + // Loop through each received TicketDayGroup + foreach (TicketDayGroup group in result.TicketDays) { + // Set each value within the row + string[] row = new string[titles.Length]; + row[0] = group.Date; + row[1] = group.DayOfWeek; + for (int i = 2; i < row.Count() - 2; i++) { + row[i] = group.ShiftCounts[i - 2].ToString(); + sums[i - 2] += group.ShiftCounts[i - 2]; + } + // Add the outside row count and total count + row[row.Count() - 2] = group.OutsideShiftCount.ToString(); + sums[sums.Count() - 2] += group.OutsideShiftCount; + row[row.Count() - 1] = group.Count.ToString(); + sums[sums.Count() - 1] += group.Count; + // Add to the list + fc.AddRange(Service.ConvertToBytes(row)); } - dayStats.Add(new Tuple("", "Total", sums[0].ToString(), sums[1].ToString(), sums[2].ToString(), sums[3].ToString())); + // Create a final row composing of totals of the numbered columns + string[] rowLater = new string[titles.Length]; + rowLater[0] = ""; + rowLater[1] = "Total"; + for (int i = 2; i < rowLater.Length; i++) { + rowLater[i] = sums[i - 2].ToString(); + } + // Add to the list + fc.AddRange(Service.ConvertToBytes(rowLater)); + + // Convert the final byte list back to an array and return the resulting file + f.FileContents = fc.ToArray(); - return ConstructManyColumnTupleReport(dayStats, "day"); + return f; } /// @@ -864,12 +908,12 @@ public async Task ExportCorrelationStats() // Helper method to create participation reports public FileContentResult ConstructParticipationReport(ParticipationReport report) { - List> participationStats = new List>(); - participationStats.Add(new Tuple(report.Key, "Game Count", "Player Count", "Players with Sessions Count")); + List> participationStats = new List>(); + participationStats.Add(new Tuple(report.Key, "Game Count", "Player Count", "Players with Sessions Count", "Team Count", "Teams with Session Count", "Challenges Deployed Count")); foreach (ParticipationStat stat in report.Stats) { - participationStats.Add(new Tuple(stat.Key, stat.GameCount.ToString(), stat.PlayerCount.ToString(), stat.SessionPlayerCount.ToString())); + participationStats.Add(new Tuple(stat.Key, stat.GameCount.ToString(), stat.PlayerCount.ToString(), stat.SessionPlayerCount.ToString(), stat.TeamCount.ToString(), stat.SessionTeamCount.ToString(), stat.ChallengesDeployedCount.ToString())); } return ConstructManyColumnTupleReport(participationStats, report.Key.ToLower()); diff --git a/src/Gameboard.Api/Features/Report/ReportModels.cs b/src/Gameboard.Api/Features/Report/ReportModels.cs index 14d46758..04b675e7 100644 --- a/src/Gameboard.Api/Features/Report/ReportModels.cs +++ b/src/Gameboard.Api/Features/Report/ReportModels.cs @@ -125,6 +125,9 @@ public class ParticipationStat public int GameCount { get; set; } public int PlayerCount { get; set; } public int SessionPlayerCount { get; set; } + public int TeamCount { get; set; } + public int SessionTeamCount { get; set; } + public int ChallengesDeployedCount { get; set; } } public class SeriesReport : ParticipationReport diff --git a/src/Gameboard.Api/Features/Report/ReportService.cs b/src/Gameboard.Api/Features/Report/ReportService.cs index e18106c6..305d7702 100644 --- a/src/Gameboard.Api/Features/Report/ReportService.cs +++ b/src/Gameboard.Api/Features/Report/ReportService.cs @@ -16,12 +16,16 @@ public class ReportService : _Service { GameboardDbContext Store { get; } ITicketStore TicketStore { get; } + Defaults Defaults { get; } ChallengeService _challengeService { get; } + string blankName = "N/A"; + public ReportService ( ILogger logger, IMapper mapper, CoreOptions options, + Defaults defaults, GameboardDbContext store, ITicketStore ticketStore, ChallengeService challengeService, @@ -31,6 +35,7 @@ GameService gameService Store = store; _challengeService = challengeService; TicketStore = ticketStore; + Defaults = defaults; } internal Task GetUserStats() @@ -100,7 +105,7 @@ internal Task GetGameSponsorsStats(string gameId) } var players = Store.Players.Where(p => p.GameId == gameId) - .Select(p => new { p.Sponsor, p.TeamId }).ToList(); + .Select(p => new { p.Sponsor, p.TeamId, p.Id, p.UserId }).ToList(); var sponsors = Store.Sponsors; @@ -114,10 +119,27 @@ internal Task GetGameSponsorsStats(string gameId) Name = sponsor.Name, Logo = sponsor.Logo, Count = players.Where(p => p.Sponsor == sponsor.Logo).Count(), - TeamCount = players.Where(p => p.Sponsor == sponsor.Logo).Select(p => p.TeamId).Distinct().Count() + TeamCount = players.Where(p => p.Sponsor == sponsor.Logo && ( + // Either every player on a team has the same sponsor, or... + players.Where(p2 => p.Id != p2.Id && p.TeamId == p2.TeamId).All(p2 => p.Sponsor == p2.Sponsor) || + // ...the team has only one player on it, so still count them + players.Where(p2 => p.TeamId == p2.TeamId).Count() == 1) + ).Select(p => p.TeamId).Distinct().Count() }); } + // Create row for multisponsor teams + sponsorStats.Add(new SponsorStat + { + Id = "Multisponsor", + Name = "Multisponsor", + Logo = "", + Count = 0, + TeamCount = players.Where(p => + players.Where(p2 => p.Id != p2.Id && p.TeamId == p2.TeamId) + .Any(p2 => p.Sponsor != p2.Sponsor)).Select(p => p.TeamId).Distinct().Count() + }); + GameSponsorStat gameSponsorStat = new GameSponsorStat { GameId = gameId, @@ -232,7 +254,7 @@ internal Task GetSeriesStats() { var tempTable = Store.Games.Select( g => new { // Replace null, white space, or empty series with "N/A" - Series = string.IsNullOrWhiteSpace(g.Competition) ? "N/A" : g.Competition + Series = string.IsNullOrWhiteSpace(g.Competition) ? blankName : g.Competition // To create the table we have to group by the series, then count the rows in each group }).GroupBy(g => g.Series).Select( s => new { @@ -240,7 +262,6 @@ internal Task GetSeriesStats() { GameCount = s.Count() }); - // Perform actual grouping logic using the above table; we group by both columns ParticipationStat[] stats = tempTable.GroupBy(g => new { g.Series, g.GameCount } ).Select( s => new ParticipationStat { // Get the formatted series @@ -248,9 +269,15 @@ internal Task GetSeriesStats() { // Get the number of games in the series GameCount = s.Key.GameCount, // Get the number of registered players in the series - PlayerCount = Store.Players.Where(p => p.Game.Competition == s.Key.Series).Select(p => p.UserId).Distinct().Count(), + PlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Competition) ? blankName : p.Game.Competition) == s.Key.Series).Select(p => p.UserId).Distinct().Count(), // Get the number of enrolled players in the series - SessionPlayerCount = Store.Players.Where(p => p.Game.Competition == s.Key.Series && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count() + SessionPlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Competition) ? blankName : p.Game.Competition) == s.Key.Series && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count(), + // Get the number of registered teams in the series + TeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Competition) ? blankName : p.Game.Competition) == s.Key.Series).Select(p => p.TeamId).Distinct().Count(), + // Get the number of enrolled teams in the series + SessionTeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Competition) ? blankName : p.Game.Competition) == s.Key.Series && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.TeamId).Distinct().Count(), + // Get the number of challenges deployed in this series + ChallengesDeployedCount = Store.ArchivedChallenges.Join(Store.Games, ac => ac.GameId, g => g.Id, (ac, g) => new { GameId = g.Id, Series = g.Competition }).Where(g => (string.IsNullOrWhiteSpace(g.Series) ? blankName : g.Series) == s.Key.Series).Count() + Store.Challenges.Join(Store.Games, c => c.GameId, g => g.Id, (c, g) => new { Series = g.Competition }).Where(g => (string.IsNullOrWhiteSpace(g.Series) ? blankName : g.Series) == s.Key.Series).Count() } ).OrderBy(stat => stat.Key).ToArray(); @@ -269,7 +296,7 @@ internal Task GetTrackStats() { var tempTable = Store.Games.Select( g => new { // Replace null, white space, or empty tracks with "N/A" - Track = string.IsNullOrWhiteSpace(g.Track) ? "N/A" : g.Track + Track = string.IsNullOrWhiteSpace(g.Track) ? blankName : g.Track // To create the table we have to group by the track, then count the rows in each group }).GroupBy(g => g.Track).Select( s => new { @@ -285,9 +312,15 @@ internal Task GetTrackStats() { // Get the number of games in the track GameCount = s.Key.GameCount, // Get the number of registered players in the track - PlayerCount = Store.Players.Where(p => p.Game.Track == s.Key.Track).Select(p => p.UserId).Distinct().Count(), + PlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Track) ? blankName : p.Game.Track) == s.Key.Track).Select(p => p.UserId).Distinct().Count(), // Get the number of enrolled players in the track - SessionPlayerCount = Store.Players.Where(p => p.Game.Track == s.Key.Track && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count() + SessionPlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Track) ? blankName : p.Game.Track) == s.Key.Track && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count(), + // Get the number of registered teams in the track + TeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Track) ? blankName : p.Game.Track) == s.Key.Track).Select(p => p.TeamId).Distinct().Count(), + // Get the number of enrolled teams in the track + SessionTeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Track) ? blankName : p.Game.Track) == s.Key.Track && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.TeamId).Distinct().Count(), + // Get the number of challenges deployed in this track + ChallengesDeployedCount = Store.ArchivedChallenges.Join(Store.Games, ac => ac.GameId, g => g.Id, (ac, g) => new { GameId = g.Id, Track = g.Track }).Where(g => (string.IsNullOrWhiteSpace(g.Track) ? blankName : g.Track) == s.Key.Track).Count() + Store.Challenges.Join(Store.Games, c => c.GameId, g => g.Id, (c, g) => new { Track = g.Track }).Where(g => (string.IsNullOrWhiteSpace(g.Track) ? blankName : g.Track) == s.Key.Track).Count() } ).OrderBy(stat => stat.Key).ToArray(); @@ -306,7 +339,7 @@ internal Task GetSeasonStats() { var tempTable = Store.Games.Select( g => new { // Replace null, white space, or empty divisions with "N/A" - Season = string.IsNullOrWhiteSpace(g.Season) ? "N/A" : g.Season + Season = string.IsNullOrWhiteSpace(g.Season) ? blankName : g.Season // To create the table we have to group by the division, then count the rows in each group }).GroupBy(g => g.Season).Select( s => new { @@ -322,19 +355,25 @@ internal Task GetSeasonStats() { // Get the number of games in the division GameCount = s.Key.GameCount, // Get the number of registered players in the division - PlayerCount = Store.Players.Where(p => p.Game.Season == s.Key.Season).Select(p => p.UserId).Distinct().Count(), + PlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Season) ? blankName : p.Game.Season) == s.Key.Season).Select(p => p.UserId).Distinct().Count(), // Get the number of enrolled players in the division - SessionPlayerCount = Store.Players.Where(p => p.Game.Season == s.Key.Season && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count() + SessionPlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Season) ? blankName : p.Game.Season) == s.Key.Season && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count(), + // Get the number of registered teams in the season + TeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Season) ? blankName : p.Game.Season) == s.Key.Season).Select(p => p.TeamId).Distinct().Count(), + // Get the number of enrolled teams in the season + SessionTeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Season) ? blankName : p.Game.Season) == s.Key.Season && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.TeamId).Distinct().Count(), + // Get the number of challenges deployed in this season + ChallengesDeployedCount = Store.ArchivedChallenges.Join(Store.Games, ac => ac.GameId, g => g.Id, (ac, g) => new { GameId = g.Id, Season = g.Season }).Where(g => (string.IsNullOrWhiteSpace(g.Season) ? blankName : g.Season) == s.Key.Season).Count() + Store.Challenges.Join(Store.Games, c => c.GameId, g => g.Id, (c, g) => new { Season = g.Season }).Where(g => (string.IsNullOrWhiteSpace(g.Season) ? blankName : g.Season) == s.Key.Season).Count() } ).OrderBy(stat => stat.Key).ToArray(); - SeasonReport divisionReport = new SeasonReport + SeasonReport seasonReport = new SeasonReport { Timestamp = DateTime.UtcNow, Stats = stats }; - return Task.FromResult(divisionReport); + return Task.FromResult(seasonReport); } internal Task GetDivisionStats() { @@ -343,7 +382,7 @@ internal Task GetDivisionStats() { var tempTable = Store.Games.Select( g => new { // Replace null, white space, or empty divisions with "N/A" - Division = string.IsNullOrWhiteSpace(g.Division) ? "N/A" : g.Division + Division = string.IsNullOrWhiteSpace(g.Division) ? blankName : g.Division // To create the table we have to group by the division, then count the rows in each group }).GroupBy(g => g.Division).Select( s => new { @@ -359,9 +398,15 @@ internal Task GetDivisionStats() { // Get the number of games in the division GameCount = s.Key.GameCount, // Get the number of registered players in the division - PlayerCount = Store.Players.Where(p => p.Game.Division == s.Key.Division).Select(p => p.UserId).Distinct().Count(), + PlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Division) ? blankName : p.Game.Division) == s.Key.Division).Select(p => p.UserId).Distinct().Count(), // Get the number of enrolled players in the division - SessionPlayerCount = Store.Players.Where(p => p.Game.Division == s.Key.Division && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count() + SessionPlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Division) ? blankName : p.Game.Division) == s.Key.Division && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count(), + // Get the number of registered teams in the division + TeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Division) ? blankName : p.Game.Division) == s.Key.Division).Select(p => p.TeamId).Distinct().Count(), + // Get the number of enrolled teams in the division + SessionTeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Division) ? blankName : p.Game.Division) == s.Key.Division && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.TeamId).Distinct().Count(), + // Get the number of challenges deployed in this division + ChallengesDeployedCount = Store.ArchivedChallenges.Join(Store.Games, ac => ac.GameId, g => g.Id, (ac, g) => new { GameId = g.Id, Division = g.Division }).Where(g => (string.IsNullOrWhiteSpace(g.Division) ? blankName : g.Division) == s.Key.Division).Count() + Store.Challenges.Join(Store.Games, c => c.GameId, g => g.Id, (c, g) => new { Division = g.Division }).Where(g => (string.IsNullOrWhiteSpace(g.Division) ? blankName : g.Division) == s.Key.Division).Count() } ).OrderBy(stat => stat.Key).ToArray(); @@ -380,7 +425,7 @@ internal Task GetModeStats() { var tempTable = Store.Games.Select( g => new { // Replace null, white space, or empty modes with "N/A" - Mode = string.IsNullOrWhiteSpace(g.Mode) ? "N/A" : g.Mode + Mode = string.IsNullOrWhiteSpace(g.Mode) ? blankName : g.Mode // To create the table we have to group by the mode, then count the rows in each group }).GroupBy(g => g.Mode).Select( s => new { @@ -396,9 +441,15 @@ internal Task GetModeStats() { // Get the number of games in the mode GameCount = s.Key.GameCount, // Get the number of registered players in the mode - PlayerCount = Store.Players.Where(p => p.Game.Mode == s.Key.Mode).Select(p => p.UserId).Distinct().Count(), + PlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Mode) ? blankName : p.Game.Mode) == s.Key.Mode).Select(p => p.UserId).Distinct().Count(), // Get the number of enrolled players in the mode - SessionPlayerCount = Store.Players.Where(p => p.Game.Mode == s.Key.Mode && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count() + SessionPlayerCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Mode) ? blankName : p.Game.Mode) == s.Key.Mode && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.UserId).Distinct().Count(), + // Get the number of registered teams in the mode + TeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Mode) ? blankName : p.Game.Mode) == s.Key.Mode).Select(p => p.TeamId).Distinct().Count(), + // Get the number of enrolled teams in the mode + SessionTeamCount = Store.Players.Where(p => (string.IsNullOrWhiteSpace(p.Game.Mode) ? blankName : p.Game.Mode) == s.Key.Mode && p.SessionBegin.ToString() != "-infinity" && p.SessionBegin > DateTimeOffset.MinValue).Select(p => p.TeamId).Distinct().Count(), + // Get the number of challenges deployed in this mode + ChallengesDeployedCount = Store.ArchivedChallenges.Join(Store.Games, ac => ac.GameId, g => g.Id, (ac, g) => new { GameId = g.Id, Mode = g.Mode }).Where(g => (string.IsNullOrWhiteSpace(g.Mode) ? blankName : g.Mode) == s.Key.Mode).Count() + Store.Challenges.Join(Store.Games, c => c.GameId, g => g.Id, (c, g) => new { Mode = g.Mode }).Where(g => (string.IsNullOrWhiteSpace(g.Mode) ? blankName : g.Mode) == s.Key.Mode).Count() } ).OrderBy(stat => stat.Key).ToArray(); @@ -505,39 +556,57 @@ internal async Task GetTicketDetails(TicketReportFilter model, s .OrderBy(detail => detail.Key).ToArray(); } - internal async Task GetTicketVolume(TicketReportFilter model) + internal async Task GetTicketVolume(TicketReportFilter model) { var q = ListFilteredTickets(model); var tickets = await q.ToArrayAsync(); - // Todo: make sure times are eastern when grouping days and shifts - var result = tickets + var ticketsGrouped = tickets .GroupBy(g => new { - Date = g.Created.ToString("MM/dd/yyyy"), - DayOfWeek = g.Created.DayOfWeek.ToString() - }) + Date = TimeZoneInfo.ConvertTime(g.Created, TimeZoneInfo.FindSystemTimeZoneById(Defaults.ShiftTimezone)).ToString("MM/dd/yyyy"), + DayOfWeek = TimeZoneInfo.ConvertTime(g.Created, TimeZoneInfo.FindSystemTimeZoneById(Defaults.ShiftTimezone)).DayOfWeek.ToString() + }); + + // Get the shifts provided in AppSettings.cs + DateTimeOffset[][] shifts = Defaults.Shifts; + + // Counts + int[][] shiftCountsByDay = new int[ticketsGrouped.Count()][]; + int[] outsideShiftCountsByDay = new int[ticketsGrouped.Count()]; + + // Set the number of days observed so far + int dayNum = 0; + + var result = ticketsGrouped .Select(g => { - var shift1Count = 0; - var shift2Count = 0; - var outsideShiftCount = 0; - g.ToList().ForEach(ticket => { - // Force convert creation to eastern standard time - DateTimeOffset tz = TimeZoneInfo.ConvertTime(ticket.Created, TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time")); - var ticketCreatedHour = tz.Hour; - if (ticketCreatedHour >= 8 && ticketCreatedHour < 16) - shift1Count += 1; - else if (ticketCreatedHour >= 16 && ticketCreatedHour < 23) - shift2Count += 1; - else - outsideShiftCount += 1; - }); - return new TicketDayGroup { + // Set the shift counts + shiftCountsByDay[dayNum] = new int[shifts.Length]; + g.ToList().ForEach(ticket => { + // Force convert creation to the default timezone (in AppSettings.cs this is Eastern Standard Time) + DateTimeOffset tz = TimeZoneInfo.ConvertTime(ticket.Created, TimeZoneInfo.FindSystemTimeZoneById(Defaults.ShiftTimezone)); + var ticketCreatedHour = tz.Hour; + // Flag to check if we've found a matching shift or not + var found = false; + // Loop through all given shifts + for (int i = 0; i < shifts.Length; i++) { + // See if the ticket falls within this shift; each shift hour is already converted to the default time + if (ticketCreatedHour >= shifts[i][0].Hour && ticketCreatedHour < shifts[i][1].Hour) { + shiftCountsByDay[dayNum][i] += 1; + found = true; + } + } + // If we haven't found a matching shift for this ticket, it's outside shift hours for this day + if (!found) outsideShiftCountsByDay[dayNum] += 1; + }); + // Increase the number of days observed + dayNum += 1; + // Create a new TicketDayGroup and set its attributes + return new TicketDayGroup { Date = g.Key.Date, DayOfWeek = g.Key.DayOfWeek, - Count = shift1Count + shift2Count + outsideShiftCount, - Shift1Count = shift1Count, - Shift2Count = shift2Count, - OutsideShiftCount = outsideShiftCount + Count = shiftCountsByDay[dayNum - 1].Sum() + outsideShiftCountsByDay[dayNum - 1], + ShiftCounts = shiftCountsByDay[dayNum - 1], + OutsideShiftCount = outsideShiftCountsByDay[dayNum - 1] }; }) .OrderByDescending(g => g.Date) @@ -546,9 +615,17 @@ internal async Task GetTicketVolume(TicketReportFilter model) // if no custom date range, only show the most recent 10 if (!model.WantsAfterStartTime && !model.WantsBeforeEndTime) result = result.Take(7); + + string[] tzWords = Defaults.ShiftTimezone.Split(" "); + string timezone = tzWords[0].First() + "" + tzWords[tzWords.Length - 1].First(); - return result.ToArray(); + TicketDayReport ticketDayReport = new TicketDayReport { + Shifts = Defaults.ShiftStrings, + Timezone = timezone, + TicketDays = result.ToArray() + }; + return ticketDayReport; } internal async Task GetTicketLabels(TicketReportFilter model) diff --git a/src/Gameboard.Api/Features/Ticket/Ticket.cs b/src/Gameboard.Api/Features/Ticket/Ticket.cs index ec75e2bd..481449ba 100644 --- a/src/Gameboard.Api/Features/Ticket/Ticket.cs +++ b/src/Gameboard.Api/Features/Ticket/Ticket.cs @@ -181,13 +181,19 @@ public class TicketReportFilter: TicketSearchFilter public bool WantsBeforeEndTime => EndRange != DateTimeOffset.MinValue; } + public class TicketDayReport + { + public string[][] Shifts { get; set; } + public string Timezone { get; set; } + public TicketDayGroup[] TicketDays { get; set; } + } + public class TicketDayGroup { public string Date { get; set; } public string DayOfWeek { get; set; } public int Count { get; set; } - public int Shift1Count { get; set; } - public int Shift2Count { get; set; } + public int[] ShiftCounts { get; set; } public int OutsideShiftCount { get; set; } } diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 0e7823a8..043816bd 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; +using System; namespace Gameboard.Api { @@ -149,6 +150,36 @@ public class Defaults public string FeedbackTemplate { get; set; } = ""; public string CertificateTemplateFile { get; set; } public string CertificateTemplate { get; set; } = ""; + // The timezone to format support shifts in + public string ShiftTimezone { get; set; } + public static string ShiftTimezoneFallback { get; set; } = "Eastern Standard Time"; + // The support shifts; each string[] is the shift start time and the shift end time + public string[][] ShiftStrings { get; set; } + public static string[][] ShiftStringsFallback { get; } = new string[][] { + new string[] { "8:00 AM", "4:00 PM" }, + new string[] { "4:00 PM", "11:00 PM" } + }; + // Get date-formatted versions of the shifts + public DateTimeOffset[][] Shifts { get; set; } + public static DateTimeOffset[][] ShiftsFallback { get; set; } = GetShifts(ShiftStringsFallback); + + // Helper method to format shifts as DateTimeOffset objects + public static DateTimeOffset[][] GetShifts(string[][] shiftStrings) { + DateTimeOffset[][] offsets = new DateTimeOffset[shiftStrings.Length][]; + // Create a new DateTimeOffset representation for every string time given + for (int i = 0; i < shiftStrings.Length; i++) + { + offsets[i] = new DateTimeOffset[] { + ConvertTime(shiftStrings[i][0], ShiftTimezoneFallback), + ConvertTime(shiftStrings[i][1], ShiftTimezoneFallback) }; + } + return offsets; + } + + // Helper method to convert a given string time into a DateTimeOffset representation + public static DateTimeOffset ConvertTime(string time, string shiftTimezone) { + return TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(time), TimeZoneInfo.FindSystemTimeZoneById(shiftTimezone)); + } } } diff --git a/src/Gameboard.Api/appsettings.conf b/src/Gameboard.Api/appsettings.conf index e3c91876..46041a9e 100644 --- a/src/Gameboard.Api/appsettings.conf +++ b/src/Gameboard.Api/appsettings.conf @@ -108,6 +108,18 @@ ## Specify a global default certificate template from an html file with only body contents # Defaults__CertificateTemplateFile = certificate-template.html +## Specify support timezone; Eastern Standard Time by default, daylight savings included +# Defaults__ShiftTimezone = Eastern Standard Time + +## Specify support intervals in EST +# To add a new shift, follow this convention: +# Defaults__ShiftStrings__shift-number__0 for the start of the shift +# Defaults__ShiftStrings__shift-number__1 for the end of the shift +# Defaults__ShiftStrings__0__0 = 8:00 AM +# Defaults__ShiftStrings__0__1 = 4:00 PM +# Defaults__ShiftStrings__1__0 = 4:00 PM +# Defaults__ShiftStrings__1__1 = 11:00 PM + ################### ## Example for appsettings.Development.conf ###################