Add attempt review to coach dashboard

This commit is contained in:
axiomlogicnexus 2026-04-27 14:46:29 +02:00
parent aa340523ba
commit 840672e714
2 changed files with 200 additions and 28 deletions

View file

@ -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>(
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>(
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<FString> 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")));

View file

@ -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<UTextBlock> PreviousQueueButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> PreviousAttemptButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> PreviousAttemptButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> StartSelectedButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> StartSelectedButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> NextAttemptButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> NextAttemptButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> 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;
};