diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp index 7038ec1..7121577 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistTraining/HyperTwistCoachDashboardWidget.cpp @@ -141,6 +141,7 @@ void UHyperTwistCoachDashboardWidget::RefreshCoachDashboardView() RefreshLiveAttemptClock(); } SyncSelectedQueueEntry(); + SyncSelectedAttempt(); UpdateDashboardPresentation(); } @@ -256,6 +257,47 @@ FHyperTwistTrainingRunState UHyperTwistCoachDashboardWidget::StartSelectedQueueE return FHyperTwistTrainingRunState(); } +bool UHyperTwistCoachDashboardWidget::SelectPreviousAttemptReview() +{ + if (CachedRunState.Attempts.Num() <= 0) + { + SelectedAttemptIndex = INDEX_NONE; + LastObservedAttemptCount = 0; + return false; + } + + if (SelectedAttemptIndex == INDEX_NONE) + { + SyncSelectedAttempt(); + return SelectedAttemptIndex != INDEX_NONE; + } + + SelectedAttemptIndex = + (SelectedAttemptIndex - 1 + CachedRunState.Attempts.Num()) % CachedRunState.Attempts.Num(); + UpdateDashboardPresentation(); + return true; +} + +bool UHyperTwistCoachDashboardWidget::SelectNextAttemptReview() +{ + if (CachedRunState.Attempts.Num() <= 0) + { + SelectedAttemptIndex = INDEX_NONE; + LastObservedAttemptCount = 0; + return false; + } + + if (SelectedAttemptIndex == INDEX_NONE) + { + SyncSelectedAttempt(); + return SelectedAttemptIndex != INDEX_NONE; + } + + SelectedAttemptIndex = (SelectedAttemptIndex + 1) % CachedRunState.Attempts.Num(); + UpdateDashboardPresentation(); + return true; +} + FHyperTwistTrainingRunStepResult UHyperTwistCoachDashboardWidget::SubmitSyntheticCurrentAttempt( const EHyperTwistTrainingAttemptResult Result, const int32 TimeMs @@ -635,11 +677,21 @@ void UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked() SelectPreviousQueueEntry(); } +void UHyperTwistCoachDashboardWidget::HandlePreviousAttemptClicked() +{ + SelectPreviousAttemptReview(); +} + void UHyperTwistCoachDashboardWidget::HandleStartSelectedClicked() { StartSelectedQueueEntry(); } +void UHyperTwistCoachDashboardWidget::HandleNextAttemptClicked() +{ + SelectNextAttemptReview(); +} + void UHyperTwistCoachDashboardWidget::HandleNextQueueClicked() { SelectNextQueueEntry(); @@ -1110,6 +1162,40 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() RecordDnfButtonLabel = RecordDnfLabelRaw; } + UHorizontalBox* AttemptReviewRow = WidgetTree->ConstructWidget( + UHorizontalBox::StaticClass(), + TEXT("CoachDashboardAttemptReviewButtons") + ); + if (AttemptReviewRow != nullptr) + { + if (UVerticalBoxSlot* AttemptReviewRowSlot = RootLayout->AddChildToVerticalBox(AttemptReviewRow)) + { + AttemptReviewRowSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 0.0f)); + } + + UTextBlock* PreviousAttemptLabelRaw = nullptr; + PreviousAttemptButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + AttemptReviewRow, + TEXT("PreviousAttemptButton"), + TEXT("PreviousAttemptButtonLabel"), + TEXT("Prev Attempt"), + PreviousAttemptLabelRaw + ); + PreviousAttemptButtonLabel = PreviousAttemptLabelRaw; + + UTextBlock* NextAttemptLabelRaw = nullptr; + NextAttemptButton = HyperTwistCoachDashboardWidgetInternal::AddButton( + WidgetTree, + AttemptReviewRow, + TEXT("NextAttemptButton"), + TEXT("NextAttemptButtonLabel"), + TEXT("Next Attempt"), + NextAttemptLabelRaw + ); + NextAttemptButtonLabel = NextAttemptLabelRaw; + } + UHorizontalBox* QueueNavRow = WidgetTree->ConstructWidget( UHorizontalBox::StaticClass(), TEXT("CoachDashboardQueueNavButtons") @@ -1227,10 +1313,18 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt() { PreviousQueueButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandlePreviousQueueClicked); } + if (PreviousAttemptButton != nullptr) + { + PreviousAttemptButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandlePreviousAttemptClicked); + } if (StartSelectedButton != nullptr) { StartSelectedButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleStartSelectedClicked); } + if (NextAttemptButton != nullptr) + { + NextAttemptButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleNextAttemptClicked); + } if (NextQueueButton != nullptr) { NextQueueButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleNextQueueClicked); @@ -1291,6 +1385,31 @@ void UHyperTwistCoachDashboardWidget::SyncSelectedQueueEntry() } } +void UHyperTwistCoachDashboardWidget::SyncSelectedAttempt() +{ + if (CachedRunState.Attempts.Num() <= 0) + { + SelectedAttemptIndex = INDEX_NONE; + LastObservedAttemptCount = 0; + return; + } + + const bool bAttemptCountChanged = LastObservedAttemptCount != CachedRunState.Attempts.Num(); + const bool bSelectionStillValid = + SelectedAttemptIndex >= 0 && SelectedAttemptIndex < CachedRunState.Attempts.Num(); + + if (bPreferLatestAttemptSelectionOnRefresh && bAttemptCountChanged) + { + SelectedAttemptIndex = CachedRunState.Attempts.Num() - 1; + } + else if (!bSelectionStillValid) + { + SelectedAttemptIndex = CachedRunState.Attempts.Num() - 1; + } + + LastObservedAttemptCount = CachedRunState.Attempts.Num(); +} + void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() { const bool bHasActiveRun = CachedRunState.Session.IsStructurallyValid() @@ -1361,15 +1480,16 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() const FString VerificationNotePreview = CachedRepositoryRoundTripVerification.VerificationNotes.Num() > 0 ? CachedRepositoryRoundTripVerification.VerificationNotes[0] : TEXT("none"); - const FHyperTwistTrainingAttempt* LastAttempt = CachedRunState.Attempts.Num() > 0 - ? &CachedRunState.Attempts.Last() + const FHyperTwistTrainingAttempt* ReviewedAttempt = + SelectedAttemptIndex >= 0 && SelectedAttemptIndex < CachedRunState.Attempts.Num() + ? &CachedRunState.Attempts[SelectedAttemptIndex] : nullptr; - FString LastAttemptPhaseSummary = TEXT("none"); - if (LastAttempt != nullptr && LastAttempt->TimingBreakdown.DerivedPhaseTimings.Num() > 0) + FString ReviewedAttemptPhaseSummary = TEXT("none"); + if (ReviewedAttempt != nullptr && ReviewedAttempt->TimingBreakdown.DerivedPhaseTimings.Num() > 0) { TArray PhaseParts; - PhaseParts.Reserve(LastAttempt->TimingBreakdown.DerivedPhaseTimings.Num()); - for (const FHyperTwistPhaseTiming& PhaseTiming : LastAttempt->TimingBreakdown.DerivedPhaseTimings) + PhaseParts.Reserve(ReviewedAttempt->TimingBreakdown.DerivedPhaseTimings.Num()); + for (const FHyperTwistPhaseTiming& PhaseTiming : ReviewedAttempt->TimingBreakdown.DerivedPhaseTimings) { PhaseParts.Add(FString::Printf( TEXT("%s %d-%d (%d ms)"), @@ -1379,8 +1499,11 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() PhaseTiming.DurationMs )); } - LastAttemptPhaseSummary = FString::Join(PhaseParts, TEXT(" | ")); + ReviewedAttemptPhaseSummary = FString::Join(PhaseParts, TEXT(" | ")); } + const FString ReviewedAttemptOrdinal = ReviewedAttempt != nullptr + ? FString::Printf(TEXT("%d/%d"), SelectedAttemptIndex + 1, CachedRunState.Attempts.Num()) + : TEXT("none"); const bool bHasSessionScopedLastStep = !LastStepResult.SessionSummary.TrainingSessionId.IsEmpty() && LastStepResult.SessionSummary.TrainingSessionId == CachedRunState.Session.TrainingSessionId; const bool bLastStepCompletedRun = bHasSessionScopedLastStep @@ -1542,59 +1665,60 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() } if (LastAttemptTextBlock != nullptr) { - if (bHasSessionScopedLastStep) + if (ReviewedAttempt != nullptr) { LastAttemptTextBlock->SetText(FText::FromString(FString::Printf( - TEXT("Last recorded attempt: case %s | completed at %s | session completed attempts %d"), - LastStepResult.SessionSummary.LastCaseId.IsEmpty() ? TEXT("n/a") : *LastStepResult.SessionSummary.LastCaseId, - LastStepResult.SessionSummary.LastCompletedAtUtc.IsEmpty() + TEXT("Attempt review: %s | case %s | result %s | completed at %s | latest submission: %s"), + *ReviewedAttemptOrdinal, + ReviewedAttempt->CaseId.IsEmpty() ? TEXT("n/a") : *ReviewedAttempt->CaseId, + *HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(ReviewedAttempt->Result), + ReviewedAttempt->CompletedAtUtc.IsEmpty() ? TEXT("n/a") - : *LastStepResult.SessionSummary.LastCompletedAtUtc, - LastStepResult.SessionSummary.CompletedAttemptCount + : *ReviewedAttempt->CompletedAtUtc, + bHasSessionScopedLastStep ? TEXT("yes") : TEXT("no") ))); } else { LastAttemptTextBlock->SetText(FText::FromString( - TEXT("Last recorded attempt: none in this dashboard session yet.") + TEXT("Attempt review: no attempts recorded in the active run yet.") )); } } if (LastAttemptTimingTextBlock != nullptr) { - if (LastAttempt != nullptr) + if (ReviewedAttempt != nullptr) { LastAttemptTimingTextBlock->SetText(FText::FromString(FString::Printf( - TEXT("Last timing: result %s | inspection %d ms | raw solve %d ms | final %d ms | penalty %s | split captures %d | did-not-finish: %s"), - *HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(LastAttempt->Result), - LastAttempt->TimingBreakdown.InspectionElapsedMs, - LastAttempt->TimingBreakdown.RawSolveTimeMs, - LastAttempt->TimingBreakdown.FinalTimeMs, - *HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(LastAttempt->TimingBreakdown.AppliedPenalty), - LastAttempt->TimingBreakdown.SplitCaptures.Num(), - LastAttempt->TimingBreakdown.bDidNotFinish ? TEXT("yes") : TEXT("no") + TEXT("Reviewed timing: inspection %d ms | raw solve %d ms | final %d ms | penalty %s | split captures %d | did-not-finish: %s"), + ReviewedAttempt->TimingBreakdown.InspectionElapsedMs, + ReviewedAttempt->TimingBreakdown.RawSolveTimeMs, + ReviewedAttempt->TimingBreakdown.FinalTimeMs, + *HyperTwistCoachDashboardWidgetInternal::EnumDisplayName(ReviewedAttempt->TimingBreakdown.AppliedPenalty), + ReviewedAttempt->TimingBreakdown.SplitCaptures.Num(), + ReviewedAttempt->TimingBreakdown.bDidNotFinish ? TEXT("yes") : TEXT("no") ))); } else { LastAttemptTimingTextBlock->SetText(FText::FromString( - TEXT("Last timing: unavailable until the active run records an attempt.") + TEXT("Reviewed timing: unavailable until the active run records an attempt.") )); } } if (LastAttemptPhaseTextBlock != nullptr) { - if (LastAttempt != nullptr) + if (ReviewedAttempt != nullptr) { LastAttemptPhaseTextBlock->SetText(FText::FromString(FString::Printf( - TEXT("Last derived phases: %s"), - *LastAttemptPhaseSummary + TEXT("Reviewed derived phases: %s"), + *ReviewedAttemptPhaseSummary ))); } else { LastAttemptPhaseTextBlock->SetText(FText::FromString( - TEXT("Last derived phases: none yet.") + TEXT("Reviewed derived phases: none yet.") )); } } @@ -2101,6 +2225,22 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation() { UndoSplitButton->SetIsEnabled(bLiveSolvePhase && LiveAttemptSplitCaptures.Num() > 0); } + if (PreviousAttemptButtonLabel != nullptr) + { + PreviousAttemptButtonLabel->SetText(FText::FromString(TEXT("Prev Attempt"))); + } + if (PreviousAttemptButton != nullptr) + { + PreviousAttemptButton->SetIsEnabled(CachedRunState.Attempts.Num() > 1); + } + if (NextAttemptButtonLabel != nullptr) + { + NextAttemptButtonLabel->SetText(FText::FromString(TEXT("Next Attempt"))); + } + if (NextAttemptButton != nullptr) + { + NextAttemptButton->SetIsEnabled(CachedRunState.Attempts.Num() > 1); + } if (PreviousQueueButtonLabel != nullptr) { PreviousQueueButtonLabel->SetText(FText::FromString(TEXT("Prev Queue"))); diff --git a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h index cfbe531..238614d 100644 --- a/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h +++ b/UnrealHyperTwist/Source/UnrealHyperTwist/Public/HyperTwistTraining/HyperTwistCoachDashboardWidget.h @@ -46,9 +46,15 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") bool bRefreshLiveAttemptClockOnTick = true; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HyperTwist|Training|Coach|Dashboard") + bool bPreferLatestAttemptSelectionOnRefresh = true; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") int32 SelectedQueueEntryIndex = INDEX_NONE; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") + int32 SelectedAttemptIndex = INDEX_NONE; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard") bool bHasLiveAttemptTimer = false; @@ -85,6 +91,12 @@ public: UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") FHyperTwistTrainingRunState StartSelectedQueueEntry(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool SelectPreviousAttemptReview(); + + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") + bool SelectNextAttemptReview(); + UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard") FHyperTwistTrainingRunStepResult SubmitSyntheticCurrentAttempt( EHyperTwistTrainingAttemptResult Result, @@ -325,12 +337,24 @@ protected: UPROPERTY(Transient) TObjectPtr PreviousQueueButtonLabel = nullptr; + UPROPERTY(Transient) + TObjectPtr PreviousAttemptButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr PreviousAttemptButtonLabel = nullptr; + UPROPERTY(Transient) TObjectPtr StartSelectedButton = nullptr; UPROPERTY(Transient) TObjectPtr StartSelectedButtonLabel = nullptr; + UPROPERTY(Transient) + TObjectPtr NextAttemptButton = nullptr; + + UPROPERTY(Transient) + TObjectPtr NextAttemptButtonLabel = nullptr; + UPROPERTY(Transient) TObjectPtr NextQueueButton = nullptr; @@ -391,15 +415,22 @@ protected: UFUNCTION() void HandlePreviousQueueClicked(); + UFUNCTION() + void HandlePreviousAttemptClicked(); + UFUNCTION() void HandleStartSelectedClicked(); + UFUNCTION() + void HandleNextAttemptClicked(); + UFUNCTION() void HandleNextQueueClicked(); private: void EnsureDefaultDashboardBuilt(); void SyncSelectedQueueEntry(); + void SyncSelectedAttempt(); void UpdateDashboardPresentation(); void RefreshLiveAttemptClock(); FString BuildDefaultDeferredUntilUtc() const; @@ -417,4 +448,5 @@ private: double LiveAttemptStartedAtSeconds = 0.0; double LiveAttemptSolveStartedAtSeconds = 0.0; int32 LiveAttemptStoredInspectionElapsedMs = 0; + int32 LastObservedAttemptCount = INDEX_NONE; };