From 7c4a249b959f195be33d94e691db4fd165bafe13 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 6 Dec 2023 12:36:43 -0500 Subject: [PATCH 01/22] Resolved an issue that caused the user name input box to behave erratically for fast typers. Also applied better styling to pending user/player name change requests. --- .../player-enroll.component.html | 3 +-- .../profile-editor.component.html | 11 ++++---- .../profile-editor.component.ts | 26 ++++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html index 60d200b6..6cea906f 100644 --- a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html +++ b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html @@ -80,7 +80,7 @@
- Your name is pending approval from an administrator. Return later or select 'Update' to see if it's + Your name is pending approval from an administrator. Return later or select "Update" to see if it's been approved.
@@ -103,7 +103,6 @@ -
{{ctx.player.nameStatus}}
This will show on the public scoreboard. Your requested display name is subject to approval and a generic moniker is used until that time. diff --git a/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.html b/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.html index 0642e4ec..83dac23f 100644 --- a/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.html +++ b/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.html @@ -16,14 +16,13 @@

1. Set your display name


- +
- Your name is pending approval from an administrator. Return later or select 'Refresh' to see if it's - been approved. - - {{currentUser.name}} - {{currentUser.approvedName}} +

+ Your request to change your display name to {{currentUser.name}} is pending + approval from an administrator. Return later or select 'Refresh' to see if it's been approved. +

diff --git a/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.ts b/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.ts index 65eee548..79a4ac2f 100644 --- a/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.ts +++ b/projects/gameboard-ui/src/app/users/components/profile-editor/profile-editor.component.ts @@ -3,11 +3,12 @@ import { Component } from '@angular/core'; import { faSyncAlt } from '@fortawesome/free-solid-svg-icons'; -import { asyncScheduler, firstValueFrom, Observable, scheduled, Subject } from 'rxjs'; -import { mergeAll, tap } from 'rxjs/operators'; +import { asyncScheduler, Observable, scheduled, Subject } from 'rxjs'; +import { debounceTime, mergeAll, switchMap, tap } from 'rxjs/operators'; import { ApiUser, ChangedUser } from '../../../api/user-models'; import { UserService as ApiUserService } from '../../../api/user.service'; import { UserService } from '../../../utility/user.service'; +import { UnsubscriberService } from '@/services/unsubscriber.service'; @Component({ selector: 'app-profile-editor', @@ -16,22 +17,31 @@ import { UserService } from '../../../utility/user.service'; }) export class ProfileEditorComponent { currentUser$: Observable; - updating$ = new Subject(); errors = []; - faSync = faSyncAlt; + private _doUpdate$ = new Subject(); + private _updated$: Observable; + faSync = faSyncAlt; disallowedName: string | null = null; disallowedReason: string | null = null; constructor( private api: ApiUserService, private userSvc: UserService, + private unsub: UnsubscriberService, ) { + this._updated$ = this._doUpdate$.pipe( + debounceTime(500), + switchMap(changedUser => this.api.update(changedUser, this.disallowedName)) + ); + + this.unsub.add(this._updated$.subscribe()); + this.currentUser$ = scheduled([ userSvc.user$, - this.updating$ + this._updated$ ], asyncScheduler).pipe( mergeAll(), tap(user => { @@ -61,13 +71,11 @@ export class ProfileEditorComponent { // Otherwise, if there is a disallowed reason as well, mark it as that reason else if (this.disallowedReason) nameStatus = this.disallowedReason; - // update the api - const updatedUser = await firstValueFrom(this.api.update({ + this._doUpdate$.next({ id: this.userSvc.user$.value.id, name, nameStatus - }, this.disallowedName)); - this.updating$.next(updatedUser!); + }); } refresh(): void { From 5845873f2f8b6d012d4f14c4b7fded4f67312981 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 6 Dec 2023 14:31:04 -0500 Subject: [PATCH 02/22] Resolved an issue that could cause the component which presents challenge questions to spam the API if a 500 is returned. --- .../gamespace-quiz.component.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts index d8d87bcd..d5d0eeea 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts @@ -20,7 +20,7 @@ export class GamespaceQuizComponent { @Output() graded = new EventEmitter(); pending = false; - errors: Error[] = []; + errors: any[] = []; protected fa = fa; constructor( @@ -29,6 +29,7 @@ export class GamespaceQuizComponent { async submit(): Promise { this.pending = true; + this.errors = []; const submission = { id: this.spec.instance!.id, @@ -36,20 +37,16 @@ export class GamespaceQuizComponent { questions: this.spec.instance!.state.challenge?.questions?.map(q => ({ answer: q.answer })), }; - await firstValueFrom(this.challengesService.grade(submission).pipe( - catchError((err, caughtChallenge) => { - this.errors.push(err); - return caughtChallenge; - }), - tap(c => { - if (c) { - this.spec.instance = c; - this.api.setColor(this.spec); - this.graded.emit(true); - } - - this.pending = false; - }) - )); + try { + const gradedChallenge = await firstValueFrom(this.challengesService.grade(submission)); + this.spec.instance = gradedChallenge; + this.api.setColor(this.spec); + this.graded.emit(true); + } + catch (err) { + this.errors.push(err); + } + + this.pending = false; } } From 79402347067b7cf469f02532a85cbcd1059a486c Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 6 Dec 2023 14:31:27 -0500 Subject: [PATCH 03/22] Remove unused refs --- .../src/app/game/gamespace-quiz/gamespace-quiz.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts index d5d0eeea..35c3e489 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts @@ -6,7 +6,7 @@ import { fa } from "@/services/font-awesome.service"; import { BoardSpec } from '../../api/board-models'; import { BoardService } from '../../api/board.service'; import { TimeWindow } from '../../api/player-models'; -import { catchError, firstValueFrom, tap } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; import { ChallengesService } from '@/api/challenges.service'; @Component({ From fe51e81ee142fe2f37defe882947b592e534ffac Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 7 Dec 2023 17:11:07 -0500 Subject: [PATCH 04/22] Initial draft of 135/165 --- .../gameboard-ui/src/app/api/board-models.ts | 1 + .../src/app/api/challenges.models.ts | 18 +++ .../src/app/api/challenges.service.ts | 10 +- .../gameboard-ui/src/app/game/game.module.ts | 2 + .../gamespace-quiz.component.html | 8 +- .../gamespace-quiz.component.ts | 114 ++++++++++++++++-- .../gameboard-page.component.ts | 17 ++- .../pipes/index-to-submitted-answer.pipe.ts | 16 +++ .../src/app/services/notification.service.ts | 2 +- 9 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts diff --git a/projects/gameboard-ui/src/app/api/board-models.ts b/projects/gameboard-ui/src/app/api/board-models.ts index 10206d1d..2115b1cc 100644 --- a/projects/gameboard-ui/src/app/api/board-models.ts +++ b/projects/gameboard-ui/src/app/api/board-models.ts @@ -7,6 +7,7 @@ import { PlayerMode, PlayerRole, TimeWindow } from "./player-models"; export interface Challenge { id: string; specId: string; + teamId: string; name: string; tag: string; startTime: Date; diff --git a/projects/gameboard-ui/src/app/api/challenges.models.ts b/projects/gameboard-ui/src/app/api/challenges.models.ts index 11892ad4..baed0515 100644 --- a/projects/gameboard-ui/src/app/api/challenges.models.ts +++ b/projects/gameboard-ui/src/app/api/challenges.models.ts @@ -43,6 +43,24 @@ export interface LocalActiveChallenge extends ActiveChallenge { session: LocalTimeWindow; } +export interface ChallengeSubmissionAnswers { + questionSetIndex: number; + answers: string[]; +} + +export interface ChallengeSubmissionViewModel { + sectionIndex: number; + answers: string[]; + submittedOn: Date; +} + +export interface GetChallengeSubmissionsResponse { + challengeId: string; + teamId: string; + pendingAnswers: ChallengeSubmissionAnswers; + submittedAnswers: ChallengeSubmissionViewModel[]; +} + export interface UserApiActiveChallenges { user: SimpleEntity; competition: ApiActiveChallenge[]; diff --git a/projects/gameboard-ui/src/app/api/challenges.service.ts b/projects/gameboard-ui/src/app/api/challenges.service.ts index 00346469..1abb4792 100644 --- a/projects/gameboard-ui/src/app/api/challenges.service.ts +++ b/projects/gameboard-ui/src/app/api/challenges.service.ts @@ -4,7 +4,7 @@ import { Observable, Subject, map, switchMap, tap } from 'rxjs'; import { Challenge, NewChallenge, SectionSubmission } from './board-models'; import { ApiUrlService } from '@/services/api-url.service'; import { activeChallengesStore } from '@/stores/active-challenges.store'; -import { ChallengeSolutionGuide, LocalActiveChallenge, UserActiveChallenges, UserApiActiveChallenges } from './challenges.models'; +import { ChallengeSolutionGuide, ChallengeSubmissionAnswers, GetChallengeSubmissionsResponse, LocalActiveChallenge, UserActiveChallenges, UserApiActiveChallenges } from './challenges.models'; import { LocalTimeWindow } from '@/core/models/api-time-window'; import { PlayerMode } from './player-models'; @@ -88,4 +88,12 @@ export class ChallengesService { public getSolutionGuide(challengeId: string): Observable { return this.http.get(this.apiUrl.build(`challenge/${challengeId}/solution-guide`)); } + + public getSubmissions(challengeId: string): Observable { + return this.http.get(this.apiUrl.build(`challenge/${challengeId}/submissions`)); + } + + public savePendingSubmission(challengeId: string, submission: ChallengeSubmissionAnswers) { + return this.http.put(this.apiUrl.build(`challenge/${challengeId}/submissions/pending`), submission); + } } diff --git a/projects/gameboard-ui/src/app/game/game.module.ts b/projects/gameboard-ui/src/app/game/game.module.ts index 3e2ada61..19af8221 100644 --- a/projects/gameboard-ui/src/app/game/game.module.ts +++ b/projects/gameboard-ui/src/app/game/game.module.ts @@ -38,6 +38,7 @@ import { SessionStartCountdownComponent } from './components/session-start-count import { TeamChallengeScoresToChallengeResultTypeCountPipe } from './pipes/team-challenge-scores-to-challenge-result-type-count.pipe'; import { UserIsPlayingGuard } from '@/guards/user-is-playing.guard'; import { UnityBoardComponent } from '../unity/unity-board/unity-board.component'; +import { IndexToSubmittedAnswerPipe } from './pipes/index-to-submitted-answer.pipe'; const MODULE_DECLARATIONS = [ CertificateComponent, @@ -72,6 +73,7 @@ const MODULE_DECLARATIONS = [ ScoreboardTeamDetailModalComponent, ContinueToGameboardButtonComponent, ExternalGameLinkBannerComponent, + IndexToSubmittedAnswerPipe, ], imports: [ CommonModule, diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html index 0ac8617d..cc3649f0 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html @@ -10,8 +10,13 @@ [class.pop-info]="!q.isGraded" [class.pop-success]="q.isGraded && q.isCorrect" [class.pop-warning]="q.isGraded && !q.isCorrect"> - + Example answer: {{q.example}} + + Your previous answers: + {{ i | indexToSubmittedAnswer:pastSubmissions }} + @@ -27,6 +32,7 @@ {{state.challenge.attempts + 1}} of {{state.challenge.maxAttempts}} +
diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts index 35c3e489..c008bcfe 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts @@ -1,20 +1,46 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { fa } from "@/services/font-awesome.service"; -import { BoardSpec } from '../../api/board-models'; +import { BoardSpec, Challenge, QuestionView } from '../../api/board-models'; import { BoardService } from '../../api/board.service'; import { TimeWindow } from '../../api/player-models'; -import { firstValueFrom } from 'rxjs'; +import { Subject, firstValueFrom } from 'rxjs'; import { ChallengesService } from '@/api/challenges.service'; +import { NotificationService } from '@/services/notification.service'; +import { UnsubscriberService } from '@/services/unsubscriber.service'; +import { ChallengeSubmissionViewModel, GetChallengeSubmissionsResponse } from '@/api/challenges.models'; + +export interface GamespaceQuizContext { + answerSections: { + sectionIndex: number; + answers: { answer: string }[]; + }[], + challenge: { + id: string; + specId: string; + teamId: string; + isDeployed: boolean + }, + session: { + msRemaining: number; + isActive: boolean; + } +} + +interface PendingSubmissionsUpdate { + challengeId: string; + sectionIndex: number; + answers: string[]; +} @Component({ selector: 'app-gamespace-quiz', templateUrl: './gamespace-quiz.component.html', styleUrls: ['./gamespace-quiz.component.scss'] }) -export class GamespaceQuizComponent { +export class GamespaceQuizComponent implements OnChanges { @Input() spec!: BoardSpec; @Input() session!: TimeWindow; @Output() graded = new EventEmitter(); @@ -22,10 +48,36 @@ export class GamespaceQuizComponent { pending = false; errors: any[] = []; protected fa = fa; + protected pastSubmissions: ChallengeSubmissionViewModel[] = []; + private _updatePendingAnswers$ = new Subject(); constructor( private api: BoardService, - private challengesService: ChallengesService) { } + private challengesService: ChallengesService, + private teamHub: NotificationService, + private unsub: UnsubscriberService) { } + + async ngOnChanges(changes: SimpleChanges): Promise { + if (this.spec.instance?.id && this.spec.instance?.id !== changes.spec?.previousValue.id) { + // check to see if they have any saved answers for this challenge - autofill if so + const submissions = await firstValueFrom(this.challengesService.getSubmissions(this.spec.instance.id)); + this.handleSubmissionsRetrieved(submissions); + } + else { + this.pastSubmissions = []; + } + + if (this.spec.instance?.teamId && changes?.spec?.previousValue?.instance?.teamId !== this.spec?.instance?.teamId) { + this.teamHub.init(this.spec.instance!.teamId); + + this.unsub.add( + // updates from the hub (like from the challenge grading server, say) + this.teamHub.challengeEvents.subscribe(c => { + this.handleChallengeUpdated(c.model); + }), + ); + } + } async submit(): Promise { this.pending = true; @@ -33,15 +85,13 @@ export class GamespaceQuizComponent { const submission = { id: this.spec.instance!.id, - sectionIndex: this.spec.instance!.state.challenge?.sectionIndex, + sectionIndex: this.spec.instance!.state.challenge?.sectionIndex || 0, questions: this.spec.instance!.state.challenge?.questions?.map(q => ({ answer: q.answer })), }; try { const gradedChallenge = await firstValueFrom(this.challengesService.grade(submission)); - this.spec.instance = gradedChallenge; - this.api.setColor(this.spec); - this.graded.emit(true); + this.handleChallengeUpdated(gradedChallenge); } catch (err) { this.errors.push(err); @@ -49,4 +99,50 @@ export class GamespaceQuizComponent { this.pending = false; } + + protected handleAnswerInput(args: { challengeId: string, sectionIndex: number, questions: QuestionView[] }) { + this._updatePendingAnswers$.next({ + challengeId: args.challengeId, + sectionIndex: args.sectionIndex, + answers: args.questions.map(q => q.answer) + }); + } + + private handleChallengeUpdated(challenge: Challenge) { + this.spec.instance = challenge; + this.api.setColor(this.spec); + this.graded.emit(true); + } + + private handleSubmissionsRetrieved(submissions: GetChallengeSubmissionsResponse) { + // determine if we have past submitted answers for question set of this challenge + this.pastSubmissions = []; + + const sectionIndex = this.spec.instance?.state.challenge.sectionIndex || 0; + if (submissions.submittedAnswers.length) { + this.pastSubmissions = submissions.submittedAnswers + .filter(s => s.sectionIndex == sectionIndex && s.answers.length == this.spec.instance?.state.challenge.questions.length); + } + } + + // private toContext(challenge: Challenge): GamespaceQuizContext { + // return { + // answerSections: [ + // { + // sectionIndex: challenge.state.challenge?.sectionIndex || 0, + // answers: challenge.state.challenge.questions.map(q => ({ answer: q.answer })) + // } + // ], + // challenge: { + // id: challenge.id, + // isDeployed: challenge.state.isActive, + // specId: challenge.specId, + // teamId: challenge.teamId, + // }, + // session: { + // msRemaining: challenge.endTime.valueOf() - challenge.startTime.valueOf(), + + // } + // }; + // } } diff --git a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts index 30c3f675..684e5598 100644 --- a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts +++ b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.ts @@ -12,13 +12,14 @@ import { BoardPlayer, BoardSpec, Challenge, NewChallenge, VmState } from '@/api/ import { BoardService } from '@/api/board.service'; import { ApiUser } from '@/api/user-models'; import { ConfigService } from '@/utility/config.service'; -import { HubState, NotificationService } from '@/services/notification.service'; +import { NotificationService } from '@/services/notification.service'; import { UserService } from '@/utility/user.service'; import { GameboardPerformanceSummaryViewModel } from '@/core/components/gameboard-performance-summary/gameboard-performance-summary.component'; import { BrowserService } from '@/services/browser.service'; import { HttpErrorResponse } from '@angular/common/http'; import { ApiError } from '@/api/models'; import { ConfirmButtonComponent } from '@/core/components/confirm-button/confirm-button.component'; +import { UnsubscriberService } from '@/services/unsubscriber.service'; @Component({ selector: 'app-gameboard-page', @@ -45,8 +46,6 @@ export class GameboardPageComponent implements OnDestroy { deploying = false; variant = 0; user$: Observable; - hubstate$: Observable; - hubsub: Subscription; cid = ''; performanceSummaryViewModel?: GameboardPerformanceSummaryViewModel; @@ -61,11 +60,14 @@ export class GameboardPageComponent implements OnDestroy { private api: BoardService, private config: ConfigService, private hub: NotificationService, + private unsub: UnsubscriberService ) { this.user$ = usersvc.user$; - this.hubstate$ = hub.state$; - this.hubsub = hub.challengeEvents.subscribe(ev => this.syncOne(ev.model as Challenge)); + + this.unsub.add( + hub.challengeEvents.subscribe(ev => this.syncOne(ev.model as Challenge)) + ); this.fetch$ = merge( route.params.pipe( @@ -155,13 +157,10 @@ export class GameboardPageComponent implements OnDestroy { } ngOnDestroy(): void { - if (!this.hubsub.closed) { - this.hubsub.unsubscribe(); - } if (this.fetch$) { this.fetch$.unsubscribe(); } } - startHub(b: BoardPlayer): void { + private startHub(b: BoardPlayer): void { if (b.session.isDuring) { this.hub.init(b.teamId); } diff --git a/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts new file mode 100644 index 00000000..2e3783fd --- /dev/null +++ b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts @@ -0,0 +1,16 @@ +import { ChallengeSubmissionViewModel } from '@/api/challenges.models'; +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ name: 'indexToSubmittedAnswer' }) +export class IndexToSubmittedAnswerPipe implements PipeTransform { + transform(index: number, arg: ChallengeSubmissionViewModel[]): string { + if (index < 0 || !arg || !arg.length) { + throw new Error("Can't use IndexToSubmittedAnswer pipe without an index and submitted answers."); + } + + if (index > arg.length) + throw new Error(`Can't use IndexToSubmittedAnswer pipe with an out-of-range index (${index}, ${arg.length})`); + + return arg.map(submission => `"${submission.answers[index]}"`).join(", "); + } +} diff --git a/projects/gameboard-ui/src/app/services/notification.service.ts b/projects/gameboard-ui/src/app/services/notification.service.ts index 453ea281..89fa53a3 100644 --- a/projects/gameboard-ui/src/app/services/notification.service.ts +++ b/projects/gameboard-ui/src/app/services/notification.service.ts @@ -10,7 +10,6 @@ import { AuthService, AuthTokenState } from '../utility/auth.service'; import { UserService } from '../api/user.service'; import { HubPlayer, Player, TimeWindow } from '../api/player-models'; import { LogService } from './log.service'; -import { LocalStorageService } from './local-storage.service'; import { ExternalGameService } from './external-game.service'; @Injectable({ providedIn: 'root' }) @@ -139,6 +138,7 @@ export class NotificationService { if (this.connection.state === HubConnectionState.Connected) { // and we're connected to the right group, do nothing if (this.connection.connectionId == groupId) { + this.log.logInfo(`Team (notification) hub is already connected to team ${groupId}.`); return; } From 1f6232ddb63111564066ea9b72d0b2498e225bb9 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 11 Dec 2023 14:42:02 -0500 Subject: [PATCH 05/22] Finished draft of 135/165. --- .../gameboard-ui/src/app/api/board.service.ts | 29 ++- .../src/app/api/challenges.models.ts | 2 +- .../countdown/countdown.component.html | 1 + .../countdown/countdown.component.scss | 0 .../countdown/countdown.component.ts | 30 +++ .../gameboard-ui/src/app/core/core.module.ts | 2 + .../src/app/core/pipes/countdown.pipe.ts | 2 +- .../gamespace-quiz.component.html | 35 ++- .../gamespace-quiz.component.ts | 232 ++++++++++++++---- .../gameboard-page.component.html | 3 +- .../pipes/index-to-submitted-answer.pipe.ts | 16 +- 11 files changed, 280 insertions(+), 72 deletions(-) create mode 100644 projects/gameboard-ui/src/app/core/components/countdown/countdown.component.html create mode 100644 projects/gameboard-ui/src/app/core/components/countdown/countdown.component.scss create mode 100644 projects/gameboard-ui/src/app/core/components/countdown/countdown.component.ts diff --git a/projects/gameboard-ui/src/app/api/board.service.ts b/projects/gameboard-ui/src/app/api/board.service.ts index bb12bc9e..e0ff3d3a 100644 --- a/projects/gameboard-ui/src/app/api/board.service.ts +++ b/projects/gameboard-ui/src/app/api/board.service.ts @@ -7,7 +7,8 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { GameSessionService } from '../services/game-session.service'; import { ConfigService } from '../utility/config.service'; -import { BoardPlayer, BoardSpec, Challenge, ChallengeSummary, ChangedChallenge, ConsoleActor, NewChallenge, ObserveChallenge, SectionSubmission, VmConsole } from './board-models'; +import { BoardPlayer, BoardSpec, Challenge, ChallengeResult, ChallengeSummary, ChangedChallenge, ConsoleActor, NewChallenge, ObserveChallenge, VmConsole } from './board-models'; +import { NowService } from '@/services/now.service'; @Injectable({ providedIn: 'root' }) export class BoardService { @@ -16,7 +17,8 @@ export class BoardService { constructor( private http: HttpClient, private config: ConfigService, - private gameSessionService: GameSessionService + private gameSessionService: GameSessionService, + private nowService: NowService ) { this.url = config.apphost + 'api'; } @@ -98,6 +100,29 @@ export class BoardService { return b; } + getChallengeColor(challengeInfo: { id: string, endTime?: Date, isDisabled: boolean, isLocked: boolean, result: ChallengeResult }) { + if (!challengeInfo.endTime) { + return "white"; + } + + if (challengeInfo.isDisabled || challengeInfo.isLocked) { + return "black"; + } + + if (challengeInfo.result == "success") { + return "lime"; + } + + if (challengeInfo.result == "partial") { + return "yellow"; + } + + if (challengeInfo.endTime < this.nowService.now()) + return "red"; + + return "blue"; + } + setColor(s: BoardSpec): void { s.c = !!s.instance?.state.id ? s.instance.state.endTime.match(/^0001/) ? 'white' : 'black' diff --git a/projects/gameboard-ui/src/app/api/challenges.models.ts b/projects/gameboard-ui/src/app/api/challenges.models.ts index baed0515..fe7044b8 100644 --- a/projects/gameboard-ui/src/app/api/challenges.models.ts +++ b/projects/gameboard-ui/src/app/api/challenges.models.ts @@ -57,7 +57,7 @@ export interface ChallengeSubmissionViewModel { export interface GetChallengeSubmissionsResponse { challengeId: string; teamId: string; - pendingAnswers: ChallengeSubmissionAnswers; + pendingAnswers: ChallengeSubmissionAnswers | null; submittedAnswers: ChallengeSubmissionViewModel[]; } diff --git a/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.html b/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.html new file mode 100644 index 00000000..26a3347b --- /dev/null +++ b/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.html @@ -0,0 +1 @@ +{{ timeRemaining$ | async | countdown }} diff --git a/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.scss b/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.ts b/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.ts new file mode 100644 index 00000000..e5e2dd35 --- /dev/null +++ b/projects/gameboard-ui/src/app/core/components/countdown/countdown.component.ts @@ -0,0 +1,30 @@ +import { NowService } from '@/services/now.service'; +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { Observable, map, of, tap, timer } from 'rxjs'; + +@Component({ + selector: 'app-countdown', + templateUrl: './countdown.component.html', + styleUrls: ['./countdown.component.scss'] +}) +export class CountdownComponent implements OnChanges { + // note that this should be in MS since epoch (e.g. new Date().valueOf()) + @Input() countdownTo?: number; + + protected timeRemaining$: Observable = of(0); + + constructor(private nowService: NowService) { } + + ngOnChanges(changes: SimpleChanges): void { + if (this.countdownTo) { + this.timeRemaining$ = timer(0, 1000).pipe( + map(() => { + if (!this.countdownTo) + return 0; + + return this.countdownTo - this.nowService.now().valueOf(); + }) + ); + } + } +} diff --git a/projects/gameboard-ui/src/app/core/core.module.ts b/projects/gameboard-ui/src/app/core/core.module.ts index da06a42e..49e38dbe 100644 --- a/projects/gameboard-ui/src/app/core/core.module.ts +++ b/projects/gameboard-ui/src/app/core/core.module.ts @@ -36,6 +36,7 @@ import { ClockPipe } from './pipes/clock.pipe'; import { ColoredTextChipComponent } from './components/colored-text-chip/colored-text-chip.component'; import { ConfirmButtonComponent } from '@/core/components/confirm-button/confirm-button.component'; import { CountdownColorPipe } from './pipes/countdown-color.pipe'; +import { CountdownComponent } from './components/countdown/countdown.component'; import { CountdownPipe } from './pipes/countdown.pipe'; import { CumulativeTimeClockComponent } from './components/cumulative-time-clock/cumulative-time-clock.component'; import { DoughnutChartComponent } from './components/doughnut-chart/doughnut-chart.component'; @@ -100,6 +101,7 @@ const PUBLIC_DECLARATIONS = [ ChallengeSolutionGuideComponent, ColoredTextChipComponent, ConfirmButtonComponent, + CountdownComponent, CumulativeTimeClockComponent, DoughnutChartComponent, DropzoneComponent, diff --git a/projects/gameboard-ui/src/app/core/pipes/countdown.pipe.ts b/projects/gameboard-ui/src/app/core/pipes/countdown.pipe.ts index c08885ff..72c2b55f 100644 --- a/projects/gameboard-ui/src/app/core/pipes/countdown.pipe.ts +++ b/projects/gameboard-ui/src/app/core/pipes/countdown.pipe.ts @@ -13,7 +13,7 @@ export class CountdownPipe implements PipeTransform { this.startSecondsAtMinute = config?.settings.countdownStartSecondsAtMinute!; } - transform(value?: number): string { + transform(value: number | null | undefined): string { if (!value || value < 0) { return "--"; } diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html index cc3649f0..d4dc4231 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.html @@ -2,7 +2,7 @@ - + @@ -10,13 +10,27 @@ [class.pop-info]="!q.isGraded" [class.pop-success]="q.isGraded && q.isCorrect" [class.pop-warning]="q.isGraded && !q.isCorrect"> - - Example answer: {{q.example}} - - Your previous answers: - {{ i | indexToSubmittedAnswer:pastSubmissions }} - + + +
+ + Example answer: {{q.example}} +
+ + +
+ {{ q.answer }} +
+ +
+ + Previous attempts: + + {{ i | indexToSubmittedAnswer:pastSubmissions:q.isCorrect }} + +
@@ -36,7 +50,10 @@
- (Session ends in {{session.countdown | countdown}}) + + + remaining +
Score: {{state.challenge.score}} of {{state.challenge.maxPoints}}
diff --git a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts index c008bcfe..a5fd11de 100644 --- a/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts +++ b/projects/gameboard-ui/src/app/game/gamespace-quiz/gamespace-quiz.component.ts @@ -1,33 +1,17 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { fa } from "@/services/font-awesome.service"; -import { BoardSpec, Challenge, QuestionView } from '../../api/board-models'; +import { AnswerSubmission, BoardSpec, Challenge } from '../../api/board-models'; import { BoardService } from '../../api/board.service'; import { TimeWindow } from '../../api/player-models'; -import { Subject, firstValueFrom } from 'rxjs'; +import { Observable, Subject, catchError, delay, filter, firstValueFrom, interval, map, of, startWith, switchMap, takeUntil, tap } from 'rxjs'; import { ChallengesService } from '@/api/challenges.service'; import { NotificationService } from '@/services/notification.service'; import { UnsubscriberService } from '@/services/unsubscriber.service'; -import { ChallengeSubmissionViewModel, GetChallengeSubmissionsResponse } from '@/api/challenges.models'; - -export interface GamespaceQuizContext { - answerSections: { - sectionIndex: number; - answers: { answer: string }[]; - }[], - challenge: { - id: string; - specId: string; - teamId: string; - isDeployed: boolean - }, - session: { - msRemaining: number; - isActive: boolean; - } -} +import { ChallengeSubmissionAnswers, ChallengeSubmissionViewModel, GetChallengeSubmissionsResponse } from '@/api/challenges.models'; +import { ToastService } from '@/utility/services/toast.service'; interface PendingSubmissionsUpdate { challengeId: string; @@ -40,31 +24,99 @@ interface PendingSubmissionsUpdate { templateUrl: './gamespace-quiz.component.html', styleUrls: ['./gamespace-quiz.component.scss'] }) -export class GamespaceQuizComponent implements OnChanges { +export class GamespaceQuizComponent implements OnInit, OnChanges { + @Input() challengeId?: string; @Input() spec!: BoardSpec; @Input() session!: TimeWindow; @Output() graded = new EventEmitter(); - pending = false; - errors: any[] = []; + protected errors: any[] = []; protected fa = fa; protected pastSubmissions: ChallengeSubmissionViewModel[] = []; + protected pending = false; + protected pendingAnswers: ChallengeSubmissionAnswers = { + questionSetIndex: 0, + answers: [] + }; + protected timeRemaining$?: Observable = of(0); + + private _answersSubmitted$ = new Subject(); private _updatePendingAnswers$ = new Subject(); constructor( private api: BoardService, private challengesService: ChallengesService, private teamHub: NotificationService, + private toastsService: ToastService, private unsub: UnsubscriberService) { } + ngOnInit(): void { + this.unsub.add( + // After the player inputs anything and then doesn't change it for 5 seconds, + // update their unsubmitted answers (we keep one set of unsubmitted answers per challenge). + // + // this is a lot like debounceTime, but we allow it to be interrupted + // and flushed if the user submits answers during the debounce window + this._updatePendingAnswers$.pipe( + switchMap(update => of(update).pipe( + delay(5000), + takeUntil(this._answersSubmitted$) + )), + switchMap(update => this.challengesService.savePendingSubmission(update.challengeId, { + questionSetIndex: update.sectionIndex, + answers: update.answers + })) + ).subscribe(), + + // save submitted answers (interrupting auto-save above as a side effect) + // and reload form data with the results + this._answersSubmitted$.pipe( + tap(submission => { + this.pending = true; + this.errors = []; + }), + filter(submission => !!submission), + tap(submission => this.pending = true), + switchMap(submission => this.challengesService.grade({ + id: submission.challengeId, + sectionIndex: submission.sectionIndex, + questions: this.pendingAnswers + .answers + .map(a => ({ answer: a || "" } as AnswerSubmission)) + })), + catchError((err, caughtChallenge) => { + this.errors.push(err); + this.pending = false; + this.resetPendingAnswers(); + + return caughtChallenge; + }), + tap(challenge => { + this.pending = false; + if (!challenge) + return; + + this.resetPendingAnswers(); + this.handleChallengeUpdated(challenge); + }), + switchMap(challenge => this.challengesService.getSubmissions(challenge.id)), + tap(submissions => this.handleSubmissionsRetrieved(submissions)) + ).subscribe() + ); + } + async ngOnChanges(changes: SimpleChanges): Promise { - if (this.spec.instance?.id && this.spec.instance?.id !== changes.spec?.previousValue.id) { - // check to see if they have any saved answers for this challenge - autofill if so - const submissions = await firstValueFrom(this.challengesService.getSubmissions(this.spec.instance.id)); - this.handleSubmissionsRetrieved(submissions); - } - else { - this.pastSubmissions = []; + const currentChallengeId = this.spec.instance?.id; + const previousChallengeId = changes.spec?.previousValue?.instance?.id; + + // on the first time we get a real challengeId that is different from the previous one, or if we haven't set up + // the object we're going to bind the inputs to for the answers... + if (currentChallengeId && (currentChallengeId !== previousChallengeId || !this.pendingAnswers.answers.length)) { + // the default state is a blank set of answers, but we might update these later from a previous submission + this.resetPendingAnswers(); + + const pastSubmissions = await firstValueFrom(this.challengesService.getSubmissions(currentChallengeId)); + this.handleSubmissionsRetrieved(pastSubmissions); } if (this.spec.instance?.teamId && changes?.spec?.previousValue?.instance?.teamId !== this.spec?.instance?.teamId) { @@ -80,51 +132,125 @@ export class GamespaceQuizComponent implements OnChanges { } async submit(): Promise { - this.pending = true; - this.errors = []; - - const submission = { - id: this.spec.instance!.id, - sectionIndex: this.spec.instance!.state.challenge?.sectionIndex || 0, - questions: this.spec.instance!.state.challenge?.questions?.map(q => ({ answer: q.answer })), - }; - - try { - const gradedChallenge = await firstValueFrom(this.challengesService.grade(submission)); - this.handleChallengeUpdated(gradedChallenge); + if (!this.spec.instance) { + throw new Error("Can't submit responses with no challenge loaded."); } - catch (err) { - this.errors.push(err); - } - - this.pending = false; + this._answersSubmitted$.next({ + challengeId: this.spec.instance.id, + sectionIndex: this.spec.instance?.state.challenge?.sectionIndex || 0, + answers: this.pendingAnswers.answers + }); } - protected handleAnswerInput(args: { challengeId: string, sectionIndex: number, questions: QuestionView[] }) { + protected handleAnswerInput(args: { challengeId: string, sectionIndex: number, answers: string[] }) { + // save the unsubmitted answers (after a debounce period) this._updatePendingAnswers$.next({ challengeId: args.challengeId, sectionIndex: args.sectionIndex, - answers: args.questions.map(q => q.answer) + answers: args.answers }); } - private handleChallengeUpdated(challenge: Challenge) { + private async handleChallengeUpdated(challenge: Challenge) { this.spec.instance = challenge; this.api.setColor(this.spec); + + const submissions = await firstValueFrom(this.challengesService.getSubmissions(challenge.id)); + this.handleSubmissionsRetrieved(submissions); + this.graded.emit(true); } private handleSubmissionsRetrieved(submissions: GetChallengeSubmissionsResponse) { + // if somehow we don't have an instance, this doesn't matter + if (!this.spec.instance) + return; + // determine if we have past submitted answers for question set of this challenge + const numberOfQuestions = this.pendingAnswers?.answers.length || 0; this.pastSubmissions = []; + // if we have previous submissions for the question set of the expected length, + // make them available to the view by storing it on pastSubmissions. const sectionIndex = this.spec.instance?.state.challenge.sectionIndex || 0; if (submissions.submittedAnswers.length) { this.pastSubmissions = submissions.submittedAnswers - .filter(s => s.sectionIndex == sectionIndex && s.answers.length == this.spec.instance?.state.challenge.questions.length); + .filter(s => s.sectionIndex == sectionIndex && s.answers.length == numberOfQuestions); + } + + // attempt to restore pending (unsubmitted) answers under these conditions + // if we also have a pending submission, use that to autofill the responses to the questions + if (!submissions.pendingAnswers) { + // no pending answers, so our job is done + this.resetPendingAnswers(); + return; + } + + const hasPendingAnswers = submissions.pendingAnswers.answers.some(a => !!a); + const canPlayCurrentChallenge = this.session?.isDuring && this.spec.instance?.state.isActive; + const isMatchingSectionIndex = submissions.pendingAnswers.questionSetIndex == sectionIndex; + const isMatchingLength = submissions.pendingAnswers.answers.length == numberOfQuestions; + + if (hasPendingAnswers && isMatchingLength && isMatchingSectionIndex && canPlayCurrentChallenge) { + this.pendingAnswers = { + questionSetIndex: sectionIndex, + answers: [...submissions.pendingAnswers.answers] + }; + + for (let i = 0; i < numberOfQuestions; i++) { + this.pendingAnswers.answers[i] = submissions.pendingAnswers.answers[i]; + } + + if (!this.session.isAfter) { + this.toastsService.showMessage("Looks like you've been here before! We automatically restored your unsubmitted answers."); + } } } + // the player's submissions are bound to the pendingAnswers property of this class. + // upon any submission for the challenge, we need to update the pending answers array with: + // - an empty string for incorrectly answered questions + // - the correct answer for any correctly answered questions + // both of these are because the game engine gets unhappy if you don't resubmit answers + // which have already been marked correct or pass nulls for any questions. + // 2) Ensure correct answers + private resetPendingAnswers() { + if (!this.spec.instance) + throw new Error("Can't reset pending answers with no loaded challenge."); + + const numberOfQuestions = this.spec.instance.state.challenge.questions.length; + this.pendingAnswers = { + questionSetIndex: this.spec.instance.state.challenge.sectionIndex || 0, + answers: new Array(numberOfQuestions) + }; + + for (let i = 0; i < numberOfQuestions; i++) { + if (this.spec.instance.state.challenge.questions[i].isCorrect) { + this.pendingAnswers.answers[i] = this.spec.instance.state.challenge.questions[i].answer; + } + else { + this.pendingAnswers.answers[i] = ""; + } + } + } + + // for a much healthier component (someday) + // export interface GamespaceQuizContext { + // answerSections: { + // sectionIndex: number; + // answers: { answer: string }[]; + // }[], + // challenge: { + // id: string; + // specId: string; + // teamId: string; + // isDeployed: boolean + // }, + // session: { + // endDate: Date + // } + // } + // private toContext(challenge: Challenge): GamespaceQuizContext { // return { // answerSections: [ @@ -140,7 +266,7 @@ export class GamespaceQuizComponent implements OnChanges { // teamId: challenge.teamId, // }, // session: { - // msRemaining: challenge.endTime.valueOf() - challenge.startTime.valueOf(), + // endDate: challenge.endTime.valueOf()e, // } // }; diff --git a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.html b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.html index 2157a61f..d843c6f1 100644 --- a/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.html +++ b/projects/gameboard-ui/src/app/game/pages/gameboard-page/gameboard-page.component.html @@ -111,7 +111,8 @@

Gamespace Resources

Challenge Questions

- +
diff --git a/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts index 2e3783fd..c7f8a673 100644 --- a/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts +++ b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts @@ -3,14 +3,20 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'indexToSubmittedAnswer' }) export class IndexToSubmittedAnswerPipe implements PipeTransform { - transform(index: number, arg: ChallengeSubmissionViewModel[]): string { - if (index < 0 || !arg || !arg.length) { + transform(index: number, submissions: ChallengeSubmissionViewModel[], hideLastResponse = false): string { + if (index < 0 || !submissions || !submissions.length) { throw new Error("Can't use IndexToSubmittedAnswer pipe without an index and submitted answers."); } - if (index > arg.length) - throw new Error(`Can't use IndexToSubmittedAnswer pipe with an out-of-range index (${index}, ${arg.length})`); + if (index > submissions.length) + throw new Error(`Can't use IndexToSubmittedAnswer pipe with an out-of-range index (${index}, ${submissions.length})`); - return arg.map(submission => `"${submission.answers[index]}"`).join(", "); + // the final submitted answer may be the correct answer (if they got the question right). We show that elsewhere, so hide + // it if requested + let displayedSubmissions = [...submissions]; + if (hideLastResponse) + displayedSubmissions = displayedSubmissions.slice(0, displayedSubmissions.length - 1); + + return displayedSubmissions.map(submission => `${submission.answers[index] ? `"${submission.answers[index]}"` : "(no response)"}`).join(", "); } } From 7f8fb4cdbc18f1316a15077f2e4b7817e5c4cc8e Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 11 Dec 2023 14:46:05 -0500 Subject: [PATCH 06/22] Don't show duplicate answers in historical attempts. --- .../src/app/game/pipes/index-to-submitted-answer.pipe.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts index c7f8a673..9f722e87 100644 --- a/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts +++ b/projects/gameboard-ui/src/app/game/pipes/index-to-submitted-answer.pipe.ts @@ -1,5 +1,6 @@ import { ChallengeSubmissionViewModel } from '@/api/challenges.models'; import { Pipe, PipeTransform } from '@angular/core'; +import { unique } from 'projects/gameboard-ui/src/tools'; @Pipe({ name: 'indexToSubmittedAnswer' }) export class IndexToSubmittedAnswerPipe implements PipeTransform { @@ -17,6 +18,9 @@ export class IndexToSubmittedAnswerPipe implements PipeTransform { if (hideLastResponse) displayedSubmissions = displayedSubmissions.slice(0, displayedSubmissions.length - 1); - return displayedSubmissions.map(submission => `${submission.answers[index] ? `"${submission.answers[index]}"` : "(no response)"}`).join(", "); + return unique( + displayedSubmissions + .map(submission => `${submission.answers[index] ? `"${submission.answers[index]}"` : "(no response)"}`) + ).join(", "); } } From 8bc74bf522d27c2192f0ed815d7b2a0bf83a8a7c Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 14 Dec 2023 11:08:03 -0500 Subject: [PATCH 07/22] Working on players report --- .../parameter-sponsor.component.html | 2 +- .../parameter-sponsor.component.ts | 2 +- .../enrollment-report.models.ts | 9 +- .../enrollment-report.service.ts | 2 +- .../players-report.component.html | 96 +++++++++++++++++++ .../players-report.component.scss | 0 .../players-report.component.ts | 54 +++++++++++ .../players-report/players-report.models.ts | 32 +++++++ .../players-report/players-report.service.ts | 19 ++++ .../support-report/support-report.service.ts | 27 ------ .../src/app/reports/reports.module.ts | 11 ++- 11 files changed, 213 insertions(+), 41 deletions(-) create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.scss create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts diff --git a/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.html b/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.html index 9e7c5076..81f2ddab 100644 --- a/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.html +++ b/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.html @@ -42,7 +42,7 @@
-

Selected: diff --git a/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.ts b/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.ts index f012c95d..b61b0343 100644 --- a/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/parameters/parameter-sponsor/parameter-sponsor.component.ts @@ -161,7 +161,7 @@ export class ParameterSponsorComponent implements OnInit { params[this.queryParamName] = validSponsorIds.join(ParameterSponsorComponent.QUERYSTRING_VALUE_DELIMITER); } - await this.routerService.updateQueryParams({ parameters: params }); + await this.routerService.updateQueryParams({ parameters: params, resetParams: ["pageNumber", "pageSize"] }); this.updateSelectionSummary(value); } } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts index 97c47ae0..c0f8c591 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts @@ -26,18 +26,13 @@ export interface EnrollmentReportParameters { tracks: string[]; } -export interface EnrollmentReportSponsorViewModel { - id: string; - name: string; - logoFileName: string; -} export interface EnrollmentReportRecord { player: { id: string; name: string; enrollDate?: Date; - sponsor?: EnrollmentReportSponsorViewModel + sponsor?: ReportSponsor }, game: ReportGame, playTime: { @@ -49,7 +44,7 @@ export interface EnrollmentReportRecord { id: string; name: string; currentCaptain: SimpleEntity; - sponsors: EnrollmentReportSponsorViewModel[]; + sponsors: ReportSponsor[]; }, challenges: { name: string; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.service.ts index 7ce6e468..789ace19 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.service.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { EnrollmentReportByGameRecord, EnrollmentReportFlatParameters, EnrollmentReportLineChartGroup, EnrollmentReportRecord, EnrollmentReportStatSummary } from './enrollment-report.models'; import { Observable, firstValueFrom, map, } from 'rxjs'; -import { ReportResults, ReportResultsWithOverallStats } from '@/reports/reports-models'; +import { ReportResults } from '@/reports/reports-models'; import { ReportsService } from '@/reports/reports.service'; import { HttpClient } from '@angular/common/http'; import { ApiUrlService } from '@/services/api-url.service'; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html new file mode 100644 index 00000000..0b86cf7e --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html @@ -0,0 +1,96 @@ +

+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Player + Deployed Challenges + + Participation +
PlayerCreated OnLast Played OnCompetitivePracticeGamesSeriesSeasonsTracks
+
+ +
+
+ +

{{ record.createdOn | shortdate }}

+

{{ record.createdOn| friendlyTime }}

+
+
+ +

{{ record.lastPlayedOn | shortdate }}

+

{{ record.lastPlayedOn | friendlyTime }}

+
+
+ {{ record.deployedCompetitiveChallengesCount }} + + {{ record.deployedPracticeChallengesCount }} + + {{ record.distinctSeriesPlayedCount }} + + {{ record.distinctTracksPlayedCount }} + + {{ record.distinctSeasonsPlayedCount }} + + {{ record.distinctGamesPlayedCount }} +
+
+
+ +
+
+ +
+
+ + + + +-- +Loading your report... diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.scss b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts new file mode 100644 index 00000000..e8d71724 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { ReportComponentBase } from '../report-base.component'; +import { PlayersReportFlatParameters, PlayersReportParameters, PlayersReportRecord } from './players-report.models'; +import { ReportKey, ReportResults, ReportViewUpdate } from '@/reports/reports-models'; +import { DateRangeQueryParamModel } from '@/core/models/date-range-query-param.model'; +import { PlayersReportService } from './players-report.service'; +import { firstValueFrom } from 'rxjs'; + +interface PlayersReportContext { + isLoading: boolean; + results?: ReportResults; + selectedParameters: PlayersReportFlatParameters; +} + +@Component({ + selector: 'app-players-report', + templateUrl: './players-report.component.html', + styleUrls: ['./players-report.component.scss'] +}) +export class PlayersReportComponent extends ReportComponentBase { + protected ctx: PlayersReportContext = { + isLoading: false, + selectedParameters: {} + }; + + protected createdDateQueryModel: DateRangeQueryParamModel | null = new DateRangeQueryParamModel({ + dateStartParamName: "createdDateStart", + dateEndParamName: "createdDateEnd" + }); + + protected lastPlayedDateQueryModel: DateRangeQueryParamModel | null = new DateRangeQueryParamModel({ + dateStartParamName: "lastPlayedDateStart", + dateEndParamName: "lastPlayedDateEnd" + }); + + constructor(private playersReportService: PlayersReportService) { + super(); + } + + protected async updateView(parameters: PlayersReportFlatParameters): Promise { + if (!this.playersReportService) { + return { metaData: await firstValueFrom(this.reportsService.getReportMetaData(ReportKey.PlayersReport)) }; + } + + this.ctx.selectedParameters = parameters; + this.ctx.results = await firstValueFrom(this.playersReportService.getReportData(parameters)); + + return { + metaData: this.ctx.results.metaData, + // might be able to pull this + // pagingResults: this.ctx.results.paging + }; + } +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts new file mode 100644 index 00000000..58339a26 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts @@ -0,0 +1,32 @@ +import { PagingArgs, SimpleEntity } from "@/api/models"; +import { ReportDateRange, ReportSponsor } from "@/reports/reports-models"; + +export interface PlayersReportFlatParameters { + createdDateStart?: string; + createdDateEnd?: string; + lastPlayedDateStart?: string; + lastPlayedDateEnd?: string; + pageNumber?: number; + pageSize?: number; + sponsors?: string; +} + +export interface PlayersReportParameters { + createdDate?: ReportDateRange; + lastPlayedDate?: ReportDateRange; + sponsors?: SimpleEntity; + paging: PagingArgs; +} + +export interface PlayersReportRecord { + user: SimpleEntity; + sponsor: ReportSponsor; + createdOn?: Date; + lastPlayedOn?: Date; + deployedCompetitiveChallengesCount: number; + deployedPracticeChallengesCount: number; + distinctGamesPlayedCount: number; + distinctSeriesPlayedCount: number; + distinctTracksPlayedCount: number; + distinctSeasonsPlayedCount: number; +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts new file mode 100644 index 00000000..9a81ba35 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { PlayersReportFlatParameters, PlayersReportRecord } from './players-report.models'; +import { ApiUrlService } from '@/services/api-url.service'; +import { HttpClient } from '@angular/common/http'; +import { ReportResults } from '@/reports/reports-models'; +import { ReportsService } from '@/reports/reports.service'; + +@Injectable({ providedIn: 'root' }) +export class PlayersReportService { + constructor( + private apiUrl: ApiUrlService, + private http: HttpClient, + private reportsService: ReportsService) { } + + getReportData(parameters: PlayersReportFlatParameters) { + parameters = this.reportsService.applyDefaultPaging(parameters); + return this.http.get>(this.apiUrl.build("reports/players")); + } +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts index a465f4d0..79c7a97b 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts @@ -46,33 +46,6 @@ export class SupportReportService { return flattened; } - public unflattenParameters(parameters: SupportReportFlatParameters) { - const defaultPaging = this.reportsService.getDefaultPaging(); - - const structured: SupportReportParameters = { - ...parameters, - gameChallengeSpec: { - gameId: parameters.gameId, - challengeSpecId: parameters.challengeSpecId - }, - labels: parameters.labels?.split(',') || [], - openedDateRange: { - dateStart: parameters.openedDateStart ? new Date(parameters.openedDateStart) : null, - dateEnd: parameters.openedDateEnd ? new Date(parameters.openedDateEnd) : null, - }, - paging: { - pageNumber: parameters.pageNumber || defaultPaging.pageNumber!, - pageSize: parameters.pageSize || defaultPaging.pageSize! - }, - statuses: this.reportsService.unflattenMultiSelectValues(parameters.statuses), - timeSinceOpen: minutesToTimeSpan(parseInt(parameters.minutesSinceOpen as any)), - timeSinceUpdate: minutesToTimeSpan(parseInt(parameters.minutesSinceUpdate as any)), - }; - - this.objectService.deleteKeys(structured, "gameId", "challengeId", "openedDateStart", "openedDateEnd"); - return structured; - } - getReportData(args: SupportReportFlatParameters): Observable> { this.reportsService.applyDefaultPaging(args); return this.http.get>(this.apiUri.build("/reports/support", args)); diff --git a/projects/gameboard-ui/src/app/reports/reports.module.ts b/projects/gameboard-ui/src/app/reports/reports.module.ts index 136598d3..815d7814 100644 --- a/projects/gameboard-ui/src/app/reports/reports.module.ts +++ b/projects/gameboard-ui/src/app/reports/reports.module.ts @@ -7,6 +7,8 @@ import { ChallengeAttemptSummaryComponent } from './components/challenge-attempt import { ChallengeOrGameFieldComponent } from './components/challenge-or-game-field/challenge-or-game-field.component'; import { EnrollmentReportByGameComponent } from './components/reports/enrollment-report/enrollment-report-by-game/enrollment-report-by-game.component'; import { EnrollmentReportSponsorPlayerCountModalComponent } from './components/reports/enrollment-report/enrollment-report-sponsor-player-count-modal/enrollment-report-sponsor-player-count-modal.component'; +import { EnrollmentReportSummaryComponent } from './components/reports/enrollment-report/enrollment-report-summary/enrollment-report-summary.component'; +import { EnrollmentReportTrendComponent } from './components/reports/enrollment-report/enrollment-report-trend/enrollment-report-trend.component'; import { NoReportRecordsComponent } from './components/no-report-records/no-report-records.component'; import { ParameterChangeLinkComponent } from './components/parameter-change-link/parameter-change-link.component'; import { ParameterDateRangeComponent } from './components/parameters/parameter-date-range/parameter-date-range.component'; @@ -15,6 +17,7 @@ import { ParameterSponsorComponent } from './components/parameters/parameter-spo import { ParameterTicketStatusComponent } from './components/parameters/parameter-ticket-status/parameter-ticket-status.component'; import { ParameterTimespanPickerComponent } from './components/parameters/parameter-timespan-picker/parameter-timespan-picker.component'; import { PlayerFieldComponent } from './components/player-field/player-field.component'; +import { PlayersReportComponent } from './components/reports/players-report/players-report.component'; import { ReportCardComponent } from './components/report-card/report-card.component'; import { ReportFieldNoValueComponent } from './components/report-field-no-value/report-field-no-value.component'; import { ReportGlobalControlsComponent } from './components/report-global-controls/report-global-controls.component'; @@ -36,8 +39,6 @@ import { ArrayFieldToClassPipe } from './pipes/array-field-to-class.pipe'; import { ArrayToCountPipe } from './pipes/array-to-count.pipe'; import { CountToTooltipClassPipe } from './pipes/count-to-tooltip-class.pipe'; import { PlayerChallengeAttemptsModalComponent } from './components/player-challenge-attempts-modal/player-challenge-attempts-modal.component'; -import { EnrollmentReportTrendComponent } from './components/reports/enrollment-report/enrollment-report-trend/enrollment-report-trend.component'; -import { EnrollmentReportSummaryComponent } from './components/reports/enrollment-report/enrollment-report-summary/enrollment-report-summary.component'; @NgModule({ declarations: [ @@ -48,10 +49,13 @@ import { EnrollmentReportSummaryComponent } from './components/reports/enrollmen EnrollmentReportComponent, EnrollmentReportByGameComponent, EnrollmentReportSponsorPlayerCountModalComponent, + EnrollmentReportSummaryComponent, + EnrollmentReportTrendComponent, ParameterGameChallengespecComponent, ParameterDateRangeComponent, ParameterTicketStatusComponent, ParameterTimespanPickerComponent, + PlayersReportComponent, ReportCardComponent, ParameterChangeLinkComponent, ReportGlobalControlsComponent, @@ -74,8 +78,6 @@ import { EnrollmentReportSummaryComponent } from './components/reports/enrollmen ReportStatSummaryComponent, PlayerChallengeAttemptsModalComponent, ParameterSponsorComponent, - EnrollmentReportTrendComponent, - EnrollmentReportSummaryComponent, ], imports: [ CommonModule, @@ -87,6 +89,7 @@ import { EnrollmentReportSummaryComponent } from './components/reports/enrollmen children: [ { path: 'enrollment', component: EnrollmentReportComponent, title: "Enrollment Report" }, { path: 'practice-area', component: PracticeModeReportComponent, title: "Practice Area Report" }, + { path: 'players', component: PlayersReportComponent, title: "Players Report" }, { path: 'support', component: SupportReportComponent, title: "Support Report" } ] } From cf22dc7addc2683bc8bfa62ab6456afe3f6688b4 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 14 Dec 2023 15:37:43 -0500 Subject: [PATCH 08/22] Finish mvp of Players Report. --- .../report-parameters-container.component.ts | 21 ++--------- .../players-report.component.html | 20 ++++++++-- .../players-report.component.ts | 37 +++++++++++++++++-- .../players-report/players-report.models.ts | 6 ++- .../players-report/players-report.service.ts | 2 +- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/projects/gameboard-ui/src/app/reports/components/report-parameters-container/report-parameters-container.component.ts b/projects/gameboard-ui/src/app/reports/components/report-parameters-container/report-parameters-container.component.ts index baa24890..ca5dcd3f 100644 --- a/projects/gameboard-ui/src/app/reports/components/report-parameters-container/report-parameters-container.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/report-parameters-container/report-parameters-container.component.ts @@ -1,8 +1,6 @@ import { ReportMetaData } from '@/reports/reports-models'; import { ActiveReportService } from '@/reports/services/active-report.service'; -import { LogService } from '@/services/log.service'; -import { AfterViewInit, Component, EventEmitter, OnDestroy, Output, ViewChild } from '@angular/core'; -import { NgForm } from '@angular/forms'; +import { Component, EventEmitter, Output } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; @Component({ @@ -20,23 +18,10 @@ import { Observable, Subscription } from 'rxjs'; Loading your report`, styleUrls: ['./report-parameters-container.component.scss'] }) -export class ReportParametersContainerComponent implements AfterViewInit, OnDestroy { +export class ReportParametersContainerComponent { @Output() cleanChange = new EventEmitter(); - @ViewChild('parametersForm') parametersForm?: NgForm; - private _valueChangesSub?: Subscription; protected metaData$: Observable = this.activeReportService.metaData$; - constructor(private activeReportService: ActiveReportService, private logService: LogService) { } - - ngAfterViewInit(): void { - if (!this.parametersForm) { - this.logService.logError("Couldn't resolve the report parameters form."); - return; - } - } - - ngOnDestroy(): void { - this._valueChangesSub?.unsubscribe(); - } + constructor(private activeReportService: ActiveReportService) { } } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html index 0b86cf7e..4705cab4 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html @@ -1,5 +1,14 @@
+ + + +
- +
@@ -28,8 +38,8 @@ - - + + @@ -89,6 +99,10 @@ + + {{ context.name }} + + diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts index e8d71724..c0ff1d72 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts @@ -5,6 +5,8 @@ import { ReportKey, ReportResults, ReportViewUpdate } from '@/reports/reports-mo import { DateRangeQueryParamModel } from '@/core/models/date-range-query-param.model'; import { PlayersReportService } from './players-report.service'; import { firstValueFrom } from 'rxjs'; +import { MultiSelectQueryParamModel } from '@/core/models/multi-select-query-param.model'; +import { SimpleEntity } from '@/api/models'; interface PlayersReportContext { isLoading: boolean; @@ -23,16 +25,47 @@ export class PlayersReportComponent extends ReportComponentBase s.name; + protected getGameValue = (s: SimpleEntity) => s.id; + protected gamesQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "games", + options: firstValueFrom(this.reportsService.getGames()), + serializer: (value: SimpleEntity) => value.id, + deserializer: (value: string, options?: SimpleEntity[]) => options!.find(g => g.id === value) || null + }); + protected lastPlayedDateQueryModel: DateRangeQueryParamModel | null = new DateRangeQueryParamModel({ dateStartParamName: "lastPlayedDateStart", dateEndParamName: "lastPlayedDateEnd" }); + protected practiceDateQueryModel: DateRangeQueryParamModel | null = new DateRangeQueryParamModel({ + dateStartParamName: "practiceDateStart", + dateEndParamName: "practiceDateEnd" + }); + + protected seasonsQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "seasons" + }); + + protected seriesQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "series" + }); + + protected tracksQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "tracks" + }); + constructor(private playersReportService: PlayersReportService) { super(); } @@ -46,9 +79,7 @@ export class PlayersReportComponent extends ReportComponentBase>(this.apiUrl.build("reports/players")); + return this.http.get>(this.apiUrl.build("reports/players", parameters)); } } From 1513083f5df52b9a66a9012e0f73d0e0158221d7 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 14 Dec 2023 16:36:10 -0500 Subject: [PATCH 09/22] Add summary card to players report. Resolves GBAPI#309 pending feedback. --- .../players-report-summary-to-stats.pipe.ts | 22 +++++++++++++++++++ .../players-report.component.html | 4 ++++ .../players-report.component.ts | 6 ++--- .../players-report/players-report.models.ts | 9 ++++++++ .../players-report/players-report.service.ts | 6 ++--- .../src/app/reports/reports.module.ts | 2 ++ 6 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts new file mode 100644 index 00000000..00ea8e9f --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { PlayersReportStatSummary } from './players-report.models'; +import { ReportSummaryStat } from '../../report-stat-summary/report-stat-summary.component'; + +@Pipe({ + name: 'playersReportSummaryToStats' +}) +export class PlayersReportSummaryToStatsPipe implements PipeTransform { + + transform(value?: PlayersReportStatSummary): ReportSummaryStat[] { + if (!value) + return []; + + return [ + { label: "Players with Deployed Competitive Challenge", value: value.usersWithDeployedCompetitiveChallengeCount }, + { label: "Players with Deployed Practice Challenge", value: value.usersWithDeployedPracticeChallengeCount }, + { label: "Players with Complete Competitive Challenge", value: value.usersWithCompletedCompetitiveChallengeCount }, + { label: "Players with Complete Practice Challenge", value: value.usersWithCompletedPracticeChallengeCount } + ]; + } + +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html index 4705cab4..c783bb0f 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html @@ -16,6 +16,10 @@ + +
PlayerCreated OnName & SponsorSigned Up On Last Played On Competitive Practice
; + results?: ReportResultsWithOverallStats; selectedParameters: PlayersReportFlatParameters; } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts index 0b215613..b0f9394b 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts @@ -1,5 +1,6 @@ import { PagingArgs, SimpleEntity } from "@/api/models"; import { ReportDateRange, ReportSponsor } from "@/reports/reports-models"; +import { ReportSummaryStat } from "../../report-stat-summary/report-stat-summary.component"; export interface PlayersReportFlatParameters { createdDateStart?: string; @@ -34,3 +35,11 @@ export interface PlayersReportRecord { distinctTracksPlayedCount: number; distinctSeasonsPlayedCount: number; } + +export interface PlayersReportStatSummary { + userCount: number; + usersWithCompletedCompetitiveChallengeCount: number; + usersWithCompletedPracticeChallengeCount: number; + usersWithDeployedCompetitiveChallengeCount: number; + usersWithDeployedPracticeChallengeCount: number; +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts index 142ecfae..cb089cf1 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; -import { PlayersReportFlatParameters, PlayersReportRecord } from './players-report.models'; +import { PlayersReportFlatParameters, PlayersReportRecord, PlayersReportStatSummary } from './players-report.models'; import { ApiUrlService } from '@/services/api-url.service'; import { HttpClient } from '@angular/common/http'; -import { ReportResults } from '@/reports/reports-models'; +import { ReportResults, ReportResultsWithOverallStats } from '@/reports/reports-models'; import { ReportsService } from '@/reports/reports.service'; @Injectable({ providedIn: 'root' }) @@ -14,6 +14,6 @@ export class PlayersReportService { getReportData(parameters: PlayersReportFlatParameters) { parameters = this.reportsService.applyDefaultPaging(parameters); - return this.http.get>(this.apiUrl.build("reports/players", parameters)); + return this.http.get>(this.apiUrl.build("reports/players", parameters)); } } diff --git a/projects/gameboard-ui/src/app/reports/reports.module.ts b/projects/gameboard-ui/src/app/reports/reports.module.ts index 815d7814..4823b3c8 100644 --- a/projects/gameboard-ui/src/app/reports/reports.module.ts +++ b/projects/gameboard-ui/src/app/reports/reports.module.ts @@ -39,6 +39,7 @@ import { ArrayFieldToClassPipe } from './pipes/array-field-to-class.pipe'; import { ArrayToCountPipe } from './pipes/array-to-count.pipe'; import { CountToTooltipClassPipe } from './pipes/count-to-tooltip-class.pipe'; import { PlayerChallengeAttemptsModalComponent } from './components/player-challenge-attempts-modal/player-challenge-attempts-modal.component'; +import { PlayersReportSummaryToStatsPipe } from './components/reports/players-report/players-report-summary-to-stats.pipe'; @NgModule({ declarations: [ @@ -78,6 +79,7 @@ import { PlayerChallengeAttemptsModalComponent } from './components/player-chall ReportStatSummaryComponent, PlayerChallengeAttemptsModalComponent, ParameterSponsorComponent, + PlayersReportSummaryToStatsPipe, ], imports: [ CommonModule, From 5274f772e82c308486009090aaf685e6e813b0bc Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Fri, 15 Dec 2023 15:49:37 -0500 Subject: [PATCH 10/22] Initial draft of Challenges Report. Still needs summary card. --- .../challenge-or-game-field.component.html | 6 +- .../reports/challenges-report.service.ts | 19 +++ .../challenges-report.component.html | 115 ++++++++++++++++++ .../challenges-report.component.scss | 0 .../challenges-report.component.ts | 68 +++++++++++ .../challenges-report.models.ts | 37 ++++++ .../players-report/players-report.models.ts | 5 - .../src/app/reports/reports.module.ts | 3 + 8 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report.service.ts create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.scss create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts diff --git a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html index ce944d14..5a3bd3b1 100644 --- a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html +++ b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html @@ -6,21 +6,21 @@ {{ game.series }} - :: + :: {{ game.season }} - :: + :: {{ game.track }} - :: + :: {{ game.name }} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report.service.ts new file mode 100644 index 00000000..99254f6f --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { ChallengesReportFlatParameters, ChallengesReportRecord, ChallengesReportStatSummary } from './challenges-report/challenges-report.models'; +import { Observable } from 'rxjs'; +import { ReportResultsWithOverallStats } from '@/reports/reports-models'; +import { ApiUrlService } from '@/services/api-url.service'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ providedIn: 'root' }) +export class ChallengesReportService { + + constructor( + private apiUrl: ApiUrlService, + private http: HttpClient + ) { } + + public getReportData(parameters: ChallengesReportFlatParameters): Observable> { + return this.http.get>(this.apiUrl.build("reports/challenges", parameters)); + } +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html new file mode 100644 index 00000000..855d03b0 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html @@ -0,0 +1,115 @@ +
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SummaryPlayers + Average Performance + + Deploys + + Solves +
ChallengeGamePlayersAvg. ScoreAvg. Solve TimeCompetitivePracticeNonePartialComplete
+ {{ record.challengeSpec.name }} + + + + {{ record.distinctPlayerCount }} + +
+ {{ record.avgScore | number:"1.0-2" }} +
of {{ record.points }}
+
+
+ + {{ record.avgCompleteSolveTimeMs | msToDuration }} + + + {{ record.deployCompetitiveCount }} + + {{ record.deployPracticeCount }} + + {{ record.solveZeroCount }} + + {{ record.solvePartialCount }} + + {{ record.solveCompleteCount }} +
+
+ + +
+
+ +
+
+ + + {{ context.name }} + + + + + +-- +Loading your report... diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.scss b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts new file mode 100644 index 00000000..6391b424 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ReportComponentBase } from '../report-base.component'; +import { ChallengesReportFlatParameters, ChallengesReportParameters, ChallengesReportRecord, ChallengesReportStatSummary } from './challenges-report.models'; +import { ReportKey, ReportResultsWithOverallStats, ReportViewUpdate } from '@/reports/reports-models'; +import { ChallengesReportService } from '../challenges-report.service'; +import { MultiSelectQueryParamModel } from '@/core/models/multi-select-query-param.model'; +import { SimpleEntity } from '@/api/models'; + +export interface ChallengesReportContext { + isLoading: boolean, + results?: ReportResultsWithOverallStats + selectedParameters: ChallengesReportFlatParameters, +} + +@Component({ + selector: 'app-challenges-report', + templateUrl: './challenges-report.component.html', + styleUrls: ['./challenges-report.component.scss'] +}) +export class ChallengesReportComponent extends ReportComponentBase { + protected ctx: ChallengesReportContext = { + isLoading: true, + selectedParameters: {} + }; + + protected games$ = this.reportsService.getGames(); + protected seasons$ = this.reportsService.getSeasons(); + protected series$ = this.reportsService.getSeries(); + protected tracks$ = this.reportsService.getTracks(); + + protected displayGameName = (s: SimpleEntity) => s.name; + protected getGameValue = (s: SimpleEntity) => s.id; + protected gamesQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "games", + options: firstValueFrom(this.reportsService.getGames()), + serializer: (value: SimpleEntity) => value.id, + deserializer: (value: string, options?: SimpleEntity[]) => options!.find(g => g.id === value) || null + }); + + protected seasonsQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "seasons" + }); + + protected seriesQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "series" + }); + + protected tracksQueryModel: MultiSelectQueryParamModel | null = new MultiSelectQueryParamModel({ + paramName: "tracks" + }); + + constructor(private challengesReportService: ChallengesReportService) { + super(); + } + + protected async updateView(parameters: ChallengesReportFlatParameters): Promise { + if (!this.challengesReportService) + return { metaData: await firstValueFrom(this.reportsService.getReportMetaData(ReportKey.ChallengesReport)) }; + + this.ctx.selectedParameters = parameters; + this.ctx.isLoading = true; + this.ctx.results = await firstValueFrom(this.challengesReportService.getReportData(parameters)); + this.ctx.isLoading = false; + + return { metaData: this.ctx.results.metaData }; + } +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts new file mode 100644 index 00000000..bb1d5080 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts @@ -0,0 +1,37 @@ +import { SimpleEntity } from "@/api/models"; +import { PlayerMode } from "@/api/player-models"; +import { ReportGame } from "@/reports/reports-models"; + +export interface ChallengesReportFlatParameters { + games?: string; + seasons?: string; + series?: string; + tracks?: string; + pageNumber?: number; + pageSize?: number; +} + +export interface ChallengesReportParameters { + +} + +export interface ChallengesReportRecord { + challengeSpec: SimpleEntity; + game: ReportGame; + playerModeCurrent: PlayerMode, + points: number; + tags: string[]; + + avgScore?: number; + avgCompleteSolveTimeMs?: number; + deployCompetitiveCount: number; + deployPracticeCount: number; + distinctPlayerCount: number; + solveCompleteCount: number; + solvePartialCount: number; + solveZeroCount: number; +} + +export interface ChallengesReportStatSummary { + +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts index b0f9394b..fa067237 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts @@ -1,6 +1,5 @@ import { PagingArgs, SimpleEntity } from "@/api/models"; import { ReportDateRange, ReportSponsor } from "@/reports/reports-models"; -import { ReportSummaryStat } from "../../report-stat-summary/report-stat-summary.component"; export interface PlayersReportFlatParameters { createdDateStart?: string; @@ -17,10 +16,6 @@ export interface PlayersReportFlatParameters { } export interface PlayersReportParameters { - createdDate?: ReportDateRange; - lastPlayedDate?: ReportDateRange; - sponsors?: SimpleEntity; - paging: PagingArgs; } export interface PlayersReportRecord { diff --git a/projects/gameboard-ui/src/app/reports/reports.module.ts b/projects/gameboard-ui/src/app/reports/reports.module.ts index 4823b3c8..5c2cbf2b 100644 --- a/projects/gameboard-ui/src/app/reports/reports.module.ts +++ b/projects/gameboard-ui/src/app/reports/reports.module.ts @@ -40,6 +40,7 @@ import { ArrayToCountPipe } from './pipes/array-to-count.pipe'; import { CountToTooltipClassPipe } from './pipes/count-to-tooltip-class.pipe'; import { PlayerChallengeAttemptsModalComponent } from './components/player-challenge-attempts-modal/player-challenge-attempts-modal.component'; import { PlayersReportSummaryToStatsPipe } from './components/reports/players-report/players-report-summary-to-stats.pipe'; +import { ChallengesReportComponent } from './components/reports/challenges-report/challenges-report.component'; @NgModule({ declarations: [ @@ -47,6 +48,7 @@ import { PlayersReportSummaryToStatsPipe } from './components/reports/players-re ArrayFieldToClassPipe, CountToTooltipClassPipe, ChallengeAttemptSummaryComponent, + ChallengesReportComponent, EnrollmentReportComponent, EnrollmentReportByGameComponent, EnrollmentReportSponsorPlayerCountModalComponent, @@ -89,6 +91,7 @@ import { PlayersReportSummaryToStatsPipe } from './components/reports/players-re path: '', component: ReportPageComponent, children: [ + { path: 'challenges', component: ChallengesReportComponent, title: "Challenges Report" }, { path: 'enrollment', component: EnrollmentReportComponent, title: "Enrollment Report" }, { path: 'practice-area', component: PracticeModeReportComponent, title: "Practice Area Report" }, { path: 'players', component: PlayersReportComponent, title: "Players Report" }, From 661449ffe208e7c1d0e556fc9ce6377d711bb0a1 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 18 Dec 2023 11:27:04 -0500 Subject: [PATCH 11/22] Mvp of #310. --- .../report-stat-summary.component.scss | 2 ++ .../report-stat-summary.component.ts | 4 +-- ...challenges-report-summary-to-stats.pipe.ts | 30 +++++++++++++++++++ .../challenges-report.component.html | 6 ++-- .../challenges-report.models.ts | 12 +++++++- .../enrollment-report.component.ts | 2 +- .../src/app/reports/reports.module.ts | 4 ++- 7 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts diff --git a/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.scss b/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.scss index 99cf0a70..71c1ec39 100644 --- a/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.scss +++ b/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.scss @@ -22,6 +22,8 @@ alert { .stat { flex-wrap: wrap; + flex-basis: 48%; + flex-grow: 1; margin: 0 1rem; text-align: center; diff --git a/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.ts b/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.ts index f1c405c3..0ea0644c 100644 --- a/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/report-stat-summary/report-stat-summary.component.ts @@ -2,7 +2,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; export interface ReportSummaryStat { label: string; - value: number | string; + value?: number | string; additionalInfo?: string; } @@ -22,6 +22,6 @@ export class ReportStatSummaryComponent implements OnChanges { this.allStats = [ this.importantStat, ...this.stats - ].filter(e => !!e) as ReportSummaryStat[]; + ].filter(e => !!e && e.value) as ReportSummaryStat[]; } } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts new file mode 100644 index 00000000..a5569258 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts @@ -0,0 +1,30 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { ReportSummaryStat } from '../../report-stat-summary/report-stat-summary.component'; +import { ChallengesReportStatSummary } from './challenges-report.models'; + +@Pipe({ name: 'challengesReportSummaryToStats' }) +export class ChallengesReportSummaryToStatsPipe implements PipeTransform { + + transform(stats: ChallengesReportStatSummary): ReportSummaryStat[] { + if (!stats) + return []; + + const outStats: ReportSummaryStat[] = [ + { label: "Challenge Attempt", value: stats.deployCount } + ]; + + if (stats.archivedDeployCount > 0) { + outStats.push({ label: "Archived Attempt", value: stats.archivedDeployCount }); + } + + if (stats.mostPopularCompetitiveChallenge) { + outStats.push({ label: "Most Popular Competitive Challenge", value: stats.mostPopularCompetitiveChallenge.challengeName, additionalInfo: `${stats.mostPopularCompetitiveChallenge.deployCount} attempts` }); + } + + if (stats.mostPopularPracticeChallenge) { + outStats.push({ label: "Most Popular Practice Challenge", value: stats.mostPopularPracticeChallenge.challengeName, additionalInfo: `${stats.mostPopularPracticeChallenge.deployCount} attempts` }); + } + + return outStats; + } +} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html index 855d03b0..affc62b3 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html @@ -11,9 +11,9 @@ [value]="getGameValue"> - +
diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts index bb1d5080..44667149 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts @@ -32,6 +32,16 @@ export interface ChallengesReportRecord { solveZeroCount: number; } -export interface ChallengesReportStatSummary { +export interface ChallengesReportStatSummaryPopularChallenge { + challengeName: string; + gameName: string; + deployCount: number; +} +export interface ChallengesReportStatSummary { + archivedDeployCount: number; + deployCount: number; + specCount: number; + mostPopularCompetitiveChallenge?: ChallengesReportStatSummaryPopularChallenge; + mostPopularPracticeChallenge?: ChallengesReportStatSummaryPopularChallenge; } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts index c610de4e..b3d311e7 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts @@ -91,7 +91,7 @@ export class EnrollmentReportComponent extends ReportComponentBase Date: Mon, 18 Dec 2023 13:01:08 -0500 Subject: [PATCH 12/22] First draft of responsive nav. Need to adjust to avoid menu markup duplication. --- .../src/app/components/nav/nav.component.html | 53 +++++++++++++++++- .../src/app/components/nav/nav.component.scss | 55 ++++++++++++++++--- .../src/app/components/nav/nav.component.ts | 2 + .../src/app/services/font-awesome.service.ts | 2 + 4 files changed, 101 insertions(+), 11 deletions(-) diff --git a/projects/gameboard-ui/src/app/components/nav/nav.component.html b/projects/gameboard-ui/src/app/components/nav/nav.component.html index e48119ea..9ccca02b 100644 --- a/projects/gameboard-ui/src/app/components/nav/nav.component.html +++ b/projects/gameboard-ui/src/app/components/nav/nav.component.html @@ -1,6 +1,5 @@ -
@@ -30,7 +30,7 @@

Summary

  • - {{ stat.label | pluralizer:stat.value }}: {{stat.value}} + {{ stat.label }}: {{stat.value}}
diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts index a5569258..fb0f72b9 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report-summary-to-stats.pipe.ts @@ -10,19 +10,16 @@ export class ChallengesReportSummaryToStatsPipe implements PipeTransform { return []; const outStats: ReportSummaryStat[] = [ - { label: "Challenge Attempt", value: stats.deployCount } + { label: "Competitive Challenge Attempts", value: stats.deployCompetitiveCount }, + { label: "Practice Challenge Attempts", value: stats.deployPracticeCount }, ]; - if (stats.archivedDeployCount > 0) { - outStats.push({ label: "Archived Attempt", value: stats.archivedDeployCount }); - } - if (stats.mostPopularCompetitiveChallenge) { - outStats.push({ label: "Most Popular Competitive Challenge", value: stats.mostPopularCompetitiveChallenge.challengeName, additionalInfo: `${stats.mostPopularCompetitiveChallenge.deployCount} attempts` }); + outStats.push({ label: "Attempts On Most Popular Competitive Challenge", value: stats.mostPopularCompetitiveChallenge.deployCount, additionalInfo: `"${stats.mostPopularCompetitiveChallenge.challengeName}"` }); } if (stats.mostPopularPracticeChallenge) { - outStats.push({ label: "Most Popular Practice Challenge", value: stats.mostPopularPracticeChallenge.challengeName, additionalInfo: `${stats.mostPopularPracticeChallenge.deployCount} attempts` }); + outStats.push({ label: "Attempts on Most Popular Practice Challenge", value: stats.mostPopularPracticeChallenge.deployCount, additionalInfo: `"${stats.mostPopularPracticeChallenge.challengeName}"` }); } return outStats; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html index affc62b3..49ab297d 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html @@ -12,7 +12,7 @@ diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts index 6391b424..219ca063 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { ReportComponentBase } from '../report-base.component'; -import { ChallengesReportFlatParameters, ChallengesReportParameters, ChallengesReportRecord, ChallengesReportStatSummary } from './challenges-report.models'; +import { ChallengesReportFlatParameters, ChallengesReportRecord, ChallengesReportStatSummary } from './challenges-report.models'; import { ReportKey, ReportResultsWithOverallStats, ReportViewUpdate } from '@/reports/reports-models'; import { ChallengesReportService } from '../challenges-report.service'; import { MultiSelectQueryParamModel } from '@/core/models/multi-select-query-param.model'; @@ -18,7 +18,7 @@ export interface ChallengesReportContext { templateUrl: './challenges-report.component.html', styleUrls: ['./challenges-report.component.scss'] }) -export class ChallengesReportComponent extends ReportComponentBase { +export class ChallengesReportComponent extends ReportComponentBase { protected ctx: ChallengesReportContext = { isLoading: true, selectedParameters: {} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts index 44667149..5e3f318a 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.models.ts @@ -11,10 +11,6 @@ export interface ChallengesReportFlatParameters { pageSize?: number; } -export interface ChallengesReportParameters { - -} - export interface ChallengesReportRecord { challengeSpec: SimpleEntity; game: ReportGame; @@ -39,8 +35,8 @@ export interface ChallengesReportStatSummaryPopularChallenge { } export interface ChallengesReportStatSummary { - archivedDeployCount: number; - deployCount: number; + deployCompetitiveCount: number; + deployPracticeCount: number; specCount: number; mostPopularCompetitiveChallenge?: ChallengesReportStatSummaryPopularChallenge; mostPopularPracticeChallenge?: ChallengesReportStatSummaryPopularChallenge; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts index b3d311e7..901d9aa7 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { EnrollmentReportFlatParameters, EnrollmentReportParameters, EnrollmentReportStatSummary, EnrollmentReportTab } from './enrollment-report.models'; +import { EnrollmentReportFlatParameters, EnrollmentReportStatSummary, EnrollmentReportTab } from './enrollment-report.models'; import { ReportKey, ReportSponsor, ReportViewUpdate } from '@/reports/reports-models'; import { EnrollmentReportService } from '@/reports/components/reports/enrollment-report/enrollment-report.service'; import { Observable, first, firstValueFrom, map, of } from 'rxjs'; @@ -24,7 +24,7 @@ interface EnrollmentReportSummaryStats { templateUrl: './enrollment-report.component.html', styleUrls: ['./enrollment-report.component.scss'] }) -export class EnrollmentReportComponent extends ReportComponentBase { +export class EnrollmentReportComponent extends ReportComponentBase { games$ = this.reportsService.getGames(); seasons$ = this.reportsService.getSeasons(); series$ = this.reportsService.getSeries(); @@ -126,10 +126,10 @@ export class EnrollmentReportComponent extends ReportComponentBase !!e) .map(e => e! as ReportSummaryStat); diff --git a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts index c0f8c591..8c4d4177 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/enrollment-report/enrollment-report.models.ts @@ -16,17 +16,6 @@ export interface EnrollmentReportFlatParameters { tracks?: string; } -export interface EnrollmentReportParameters { - enrollDate?: ReportDateRange; - paging: PagingArgs; - seasons: string[]; - series: string[]; - sponsors: SimpleEntity[]; - tab?: EnrollmentReportTab; - tracks: string[]; -} - - export interface EnrollmentReportRecord { player: { id: string; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts index 00ea8e9f..13266f8e 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report-summary-to-stats.pipe.ts @@ -2,9 +2,7 @@ import { Pipe, PipeTransform } from '@angular/core'; import { PlayersReportStatSummary } from './players-report.models'; import { ReportSummaryStat } from '../../report-stat-summary/report-stat-summary.component'; -@Pipe({ - name: 'playersReportSummaryToStats' -}) +@Pipe({ name: 'playersReportSummaryToStats' }) export class PlayersReportSummaryToStatsPipe implements PipeTransform { transform(value?: PlayersReportStatSummary): ReportSummaryStat[] { @@ -13,8 +11,8 @@ export class PlayersReportSummaryToStatsPipe implements PipeTransform { return [ { label: "Players with Deployed Competitive Challenge", value: value.usersWithDeployedCompetitiveChallengeCount }, - { label: "Players with Deployed Practice Challenge", value: value.usersWithDeployedPracticeChallengeCount }, { label: "Players with Complete Competitive Challenge", value: value.usersWithCompletedCompetitiveChallengeCount }, + { label: "Players with Deployed Practice Challenge", value: value.usersWithDeployedPracticeChallengeCount }, { label: "Players with Complete Practice Challenge", value: value.usersWithCompletedPracticeChallengeCount } ]; } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html index c783bb0f..2ff742df 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.html @@ -9,7 +9,7 @@ - diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts index 6d4783ae..a52ef5d3 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { ReportComponentBase } from '../report-base.component'; -import { PlayersReportFlatParameters, PlayersReportParameters, PlayersReportRecord, PlayersReportStatSummary } from './players-report.models'; +import { PlayersReportFlatParameters, PlayersReportRecord, PlayersReportStatSummary } from './players-report.models'; import { ReportKey, ReportResultsWithOverallStats, ReportViewUpdate } from '@/reports/reports-models'; import { DateRangeQueryParamModel } from '@/core/models/date-range-query-param.model'; import { PlayersReportService } from './players-report.service'; @@ -19,7 +19,7 @@ interface PlayersReportContext { templateUrl: './players-report.component.html', styleUrls: ['./players-report.component.scss'] }) -export class PlayersReportComponent extends ReportComponentBase { +export class PlayersReportComponent extends ReportComponentBase { protected ctx: PlayersReportContext = { isLoading: false, selectedParameters: {} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts index fa067237..96065200 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/players-report/players-report.models.ts @@ -1,5 +1,5 @@ -import { PagingArgs, SimpleEntity } from "@/api/models"; -import { ReportDateRange, ReportSponsor } from "@/reports/reports-models"; +import { SimpleEntity } from "@/api/models"; +import { ReportSponsor } from "@/reports/reports-models"; export interface PlayersReportFlatParameters { createdDateStart?: string; @@ -15,9 +15,6 @@ export interface PlayersReportFlatParameters { pageSize?: number; } -export interface PlayersReportParameters { -} - export interface PlayersReportRecord { user: SimpleEntity; sponsor: ReportSponsor; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report-by-player-mode-performance/practice-mode-report-by-player-mode-performance.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report-by-player-mode-performance/practice-mode-report-by-player-mode-performance.component.ts index 4f6c9107..9572b854 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report-by-player-mode-performance/practice-mode-report-by-player-mode-performance.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report-by-player-mode-performance/practice-mode-report-by-player-mode-performance.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; -import { PracticeModeReportByPlayerModePerformanceRecord, PracticeModeReportByUserRecord, PracticeModeReportFlatParameters, PracticeModeReportOverallStats, PracticeModeReportParameters } from '../practice-mode-report.models'; +import { PracticeModeReportByPlayerModePerformanceRecord, PracticeModeReportByUserRecord, PracticeModeReportFlatParameters, PracticeModeReportOverallStats } from '../practice-mode-report.models'; import { ReportResultsWithOverallStats } from '@/reports/reports-models'; import { firstValueFrom } from 'rxjs'; import { PracticeModeReportService } from '@/reports/components/reports/practice-mode-report/practice-mode-report.service'; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.component.ts index 267c0699..5db9cef5 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { PracticeModeReportParameters, PracticeModeReportByChallengeRecord, PracticeModeReportGrouping, PracticeModeReportFlatParameters, PracticeModeReportOverallStats } from './practice-mode-report.models'; +import { PracticeModeReportByChallengeRecord, PracticeModeReportGrouping, PracticeModeReportFlatParameters, PracticeModeReportOverallStats } from './practice-mode-report.models'; import { ReportKey, ReportResults, ReportSponsor } from '@/reports/reports-models'; import { Observable, firstValueFrom } from 'rxjs'; import { SimpleEntity } from '@/api/models'; @@ -15,7 +15,7 @@ import { ReportSummaryStat } from '../../report-stat-summary/report-stat-summary styleUrls: ['./practice-mode-report.component.scss'] }) export class PracticeModeReportComponent - extends ReportComponentBase { + extends ReportComponentBase { protected overallStats: ReportSummaryStat[] = []; protected games$: Observable = this.reportsService.getGames(); protected seasons$: Observable = this.reportsService.getSeasons(); @@ -71,10 +71,10 @@ export class PracticeModeReportComponent protected handleOverallStatsUpdate(stats: PracticeModeReportOverallStats) { this.overallStats = [ - { label: "Attempt", value: stats.attemptCount }, - { label: "Challenge", value: stats.challengeCount }, - { label: "Player", value: stats.playerCount }, - { label: "Sponsor", value: stats.sponsorCount } + { label: "Attempts", value: stats.attemptCount }, + { label: "Challenges", value: stats.challengeCount }, + { label: "Players", value: stats.playerCount }, + { label: "Sponsors", value: stats.sponsorCount } ]; } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.models.ts index 7a75ed61..145e78c8 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/practice-mode-report/practice-mode-report.models.ts @@ -28,17 +28,6 @@ export interface PracticeModeReportFlatParameters { grouping: PracticeModeReportGrouping; } -export interface PracticeModeReportParameters { - practiceDate: ReportDateRange, - games: SimpleEntity[], - seasons: string[], - series: string[], - sponsors: ReportSponsor[], - tracks: string[], - paging: PagingArgs, - grouping: PracticeModeReportGrouping; -} - export interface PracticeModeReportRecord { } export interface PracticeModeReportByUserRecord extends PracticeModeReportRecord { diff --git a/projects/gameboard-ui/src/app/reports/components/reports/report-base.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/report-base.component.ts index c0f880dd..b39dc6e0 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/report-base.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/report-base.component.ts @@ -9,7 +9,7 @@ import { ObjectService } from "@/services/object.service"; import { RouterService } from "@/services/router.service"; @Component({ template: '', providers: [UnsubscriberService] }) -export abstract class ReportComponentBase implements OnInit { +export abstract class ReportComponentBase implements OnInit { // manually injected services (so the inheritors don't have to resolve dependencies) protected readonly activeReportService = inject(ActiveReportService); protected readonly reportsService = inject(ReportsService); diff --git a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.component.ts index 2ce3eac4..05fcc82b 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { SupportReportFlatParameters, SupportReportParameters, SupportReportRecord } from './support-report.models'; +import { SupportReportFlatParameters, SupportReportRecord } from './support-report.models'; import { ReportResults, ReportViewUpdate } from '../../../reports-models'; import { firstValueFrom } from 'rxjs'; import { SupportReportService } from '@/reports/components/reports/support-report/support-report.service'; @@ -26,7 +26,7 @@ interface SupportReportContext { templateUrl: './support-report.component.html', styleUrls: ['./support-report.component.scss'] }) -export class SupportReportComponent extends ReportComponentBase { +export class SupportReportComponent extends ReportComponentBase { protected ticketLabels$ = this.reportService.getTicketLabels(); protected ticketStatuses$ = this.reportService.getTicketStatuses(); diff --git a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.models.ts b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.models.ts index f06c3447..c1091914 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.models.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.models.ts @@ -1,17 +1,4 @@ -import { PagingArgs, SimpleEntity } from "projects/gameboard-ui/src/app/api/models"; -import { ReportDateRange, ReportMetaData, ReportResults, ReportTimeSpan } from "../../../reports-models"; -import { ReportGameChallengeSpec } from "../../parameters/parameter-game-challengespec/parameter-game-challengespec.component"; - -export interface SupportReportParameters { - gameChallengeSpec: ReportGameChallengeSpec; - labels: string[]; - paging: PagingArgs, - timeSinceOpen: ReportTimeSpan, - timeSinceUpdate: ReportTimeSpan, - openedDateRange: ReportDateRange; - openedTimeWindow?: SupportReportTicketWindow; - statuses?: string[]; -} +import { SimpleEntity } from "projects/gameboard-ui/src/app/api/models"; export interface SupportReportFlatParameters { challengeSpecId?: string; diff --git a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts index 79c7a97b..092dc771 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/support-report/support-report.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; -import { SupportReportFlatParameters, SupportReportParameters, SupportReportRecord } from './support-report.models'; -import { ObjectService } from '../../../../services/object.service'; +import { SupportReportFlatParameters, SupportReportRecord } from './support-report.models'; import { Observable, combineLatest, map } from 'rxjs'; import { ReportResults, minutesToTimeSpan, timespanToMinutes } from '../../../reports-models'; import { HttpClient } from '@angular/common/http'; @@ -14,38 +13,9 @@ export class SupportReportService { constructor( private apiUri: ApiUrlService, private http: HttpClient, - private objectService: ObjectService, private reportsService: ReportsService, private supportService: SupportService) { } - public flattenParameters(parameters: SupportReportParameters) { - const defaultPaging = this.reportsService.getDefaultPaging(); - - let flattened: SupportReportFlatParameters = { - ...parameters, - challengeSpecId: parameters.gameChallengeSpec?.challengeSpecId, - gameId: parameters.gameChallengeSpec?.gameId, - labels: parameters.labels?.join(','), - openedDateStart: parameters.openedDateRange?.dateStart?.toLocaleDateString(), - openedDateEnd: parameters.openedDateRange?.dateEnd?.toLocaleDateString(), - minutesSinceOpen: timespanToMinutes(parameters.timeSinceOpen), - minutesSinceUpdate: timespanToMinutes(parameters.timeSinceUpdate), - pageNumber: parameters.paging.pageNumber || defaultPaging.pageNumber!, - pageSize: parameters.paging.pageSize || defaultPaging.pageSize!, - statuses: this.reportsService.flattenMultiSelectValues(parameters.statuses) || undefined - }; - - flattened = this.objectService.deleteKeys( - flattened, - "gameChallengeSpec", - "openedDateRange", - "timeSinceOpen", - "timeSinceUpdate" - ); - - return flattened; - } - getReportData(args: SupportReportFlatParameters): Observable> { this.reportsService.applyDefaultPaging(args); return this.http.get>(this.apiUri.build("/reports/support", args)); From bbc73735468fee66cf2ec5aa44690fcb3e3efd54 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 18 Dec 2023 16:11:24 -0500 Subject: [PATCH 14/22] More fiddling with responsive menu and other not-as-responsive things. --- projects/gameboard-ui/src/app/app.component.html | 1 + .../src/app/components/nav/nav.component.html | 3 ++- .../src/app/components/nav/nav.component.scss | 11 ++++------- .../src/app/components/nav/nav.component.ts | 1 + projects/gameboard-ui/src/app/core/core.module.ts | 2 ++ .../scoreboard-table/scoreboard-table.component.scss | 3 ++- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/projects/gameboard-ui/src/app/app.component.html b/projects/gameboard-ui/src/app/app.component.html index d04dafdc..66f4680c 100644 --- a/projects/gameboard-ui/src/app/app.component.html +++ b/projects/gameboard-ui/src/app/app.component.html @@ -6,6 +6,7 @@
+ diff --git a/projects/gameboard-ui/src/app/components/nav/nav.component.html b/projects/gameboard-ui/src/app/components/nav/nav.component.html index 9ccca02b..a491d7e4 100644 --- a/projects/gameboard-ui/src/app/components/nav/nav.component.html +++ b/projects/gameboard-ui/src/app/components/nav/nav.component.html @@ -31,12 +31,13 @@ -