Retain closure recovery history on dashboard

This commit is contained in:
axiomlogicnexus 2026-04-27 21:32:52 +02:00
parent 77c8e66539
commit 61cef5b7d6
2 changed files with 367 additions and 0 deletions

View file

@ -186,11 +186,13 @@ void UHyperTwistCoachDashboardWidget::RefreshCoachDashboardView()
RefreshLiveAttemptClock();
}
RecordQueueSuppressionSnapshot();
RecordClosureRecoverySnapshot();
SyncRetainedRunRecap();
SyncSelectedQueueEntry();
SyncSelectedAttempt();
SyncSelectedGuidanceDecisionHistoryEntry();
SyncSelectedQueueSuppressionHistoryEntry();
SyncSelectedClosureRecoveryHistoryEntry();
SyncPreferredGuidancePreviewLane(false);
UpdateDashboardPresentation();
}
@ -1031,6 +1033,47 @@ bool UHyperTwistCoachDashboardWidget::SelectNextQueueSuppressionHistoryEntry()
return true;
}
bool UHyperTwistCoachDashboardWidget::SelectPreviousClosureRecoveryHistoryEntry()
{
if (ClosureRecoveryHistoryEntries.Num() <= 0)
{
SelectedClosureRecoveryIndex = INDEX_NONE;
return false;
}
if (SelectedClosureRecoveryIndex == INDEX_NONE)
{
SyncSelectedClosureRecoveryHistoryEntry();
return SelectedClosureRecoveryIndex != INDEX_NONE;
}
SelectedClosureRecoveryIndex =
(SelectedClosureRecoveryIndex - 1 + ClosureRecoveryHistoryEntries.Num())
% ClosureRecoveryHistoryEntries.Num();
UpdateDashboardPresentation();
return true;
}
bool UHyperTwistCoachDashboardWidget::SelectNextClosureRecoveryHistoryEntry()
{
if (ClosureRecoveryHistoryEntries.Num() <= 0)
{
SelectedClosureRecoveryIndex = INDEX_NONE;
return false;
}
if (SelectedClosureRecoveryIndex == INDEX_NONE)
{
SyncSelectedClosureRecoveryHistoryEntry();
return SelectedClosureRecoveryIndex != INDEX_NONE;
}
SelectedClosureRecoveryIndex =
(SelectedClosureRecoveryIndex + 1) % ClosureRecoveryHistoryEntries.Num();
UpdateDashboardPresentation();
return true;
}
bool UHyperTwistCoachDashboardWidget::StartLiveAttemptTimer()
{
if (!CachedRunState.Session.IsStructurallyValid() || !CachedRunState.CurrentSelection.TrainingCase.IsStructurallyValid())
@ -1326,6 +1369,16 @@ void UHyperTwistCoachDashboardWidget::HandleNextQueueSuppressionClicked()
SelectNextQueueSuppressionHistoryEntry();
}
void UHyperTwistCoachDashboardWidget::HandlePreviousClosureRecoveryClicked()
{
SelectPreviousClosureRecoveryHistoryEntry();
}
void UHyperTwistCoachDashboardWidget::HandleNextClosureRecoveryClicked()
{
SelectNextClosureRecoveryHistoryEntry();
}
void UHyperTwistCoachDashboardWidget::HandleClearRunClicked()
{
ClearActiveTrainingRun();
@ -2036,6 +2089,24 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt()
TEXT("CoachClosureRecoveryDetail"),
8
);
ClosureRecoveryHistoryHeaderTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow(
WidgetTree,
RootLayout,
TEXT("CoachClosureRecoveryHistoryHeader"),
2
);
ClosureRecoveryHistoryStatusTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow(
WidgetTree,
RootLayout,
TEXT("CoachClosureRecoveryHistoryStatus"),
2
);
ClosureRecoveryHistoryDetailTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow(
WidgetTree,
RootLayout,
TEXT("CoachClosureRecoveryHistoryDetail"),
8
);
QueueRecoveryHeaderTextBlock = HyperTwistCoachDashboardWidgetInternal::AddTextRow(
WidgetTree,
RootLayout,
@ -2210,6 +2281,40 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt()
NextQueueSuppressionButtonLabel = NextQueueSuppressionLabelRaw;
}
UHorizontalBox* ClosureRecoveryNavRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("CoachDashboardClosureRecoveryNavRow")
);
if (ClosureRecoveryNavRow != nullptr)
{
if (UVerticalBoxSlot* ClosureRecoveryNavRowSlot = RootLayout->AddChildToVerticalBox(ClosureRecoveryNavRow))
{
ClosureRecoveryNavRowSlot->SetPadding(FMargin(0.0f, 4.0f, 0.0f, 0.0f));
}
UTextBlock* PreviousClosureRecoveryLabelRaw = nullptr;
PreviousClosureRecoveryButton = HyperTwistCoachDashboardWidgetInternal::AddButton(
WidgetTree,
ClosureRecoveryNavRow,
TEXT("PreviousClosureRecoveryButton"),
TEXT("PreviousClosureRecoveryButtonLabel"),
TEXT("Prev Closure"),
PreviousClosureRecoveryLabelRaw
);
PreviousClosureRecoveryButtonLabel = PreviousClosureRecoveryLabelRaw;
UTextBlock* NextClosureRecoveryLabelRaw = nullptr;
NextClosureRecoveryButton = HyperTwistCoachDashboardWidgetInternal::AddButton(
WidgetTree,
ClosureRecoveryNavRow,
TEXT("NextClosureRecoveryButton"),
TEXT("NextClosureRecoveryButtonLabel"),
TEXT("Next Closure"),
NextClosureRecoveryLabelRaw
);
NextClosureRecoveryButtonLabel = NextClosureRecoveryLabelRaw;
}
UHorizontalBox* AttemptInputRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("CoachDashboardAttemptInputRow")
@ -2524,6 +2629,20 @@ void UHyperTwistCoachDashboardWidget::EnsureDefaultDashboardBuilt()
&UHyperTwistCoachDashboardWidget::HandleNextQueueSuppressionClicked
);
}
if (PreviousClosureRecoveryButton != nullptr)
{
PreviousClosureRecoveryButton->OnClicked.AddDynamic(
this,
&UHyperTwistCoachDashboardWidget::HandlePreviousClosureRecoveryClicked
);
}
if (NextClosureRecoveryButton != nullptr)
{
NextClosureRecoveryButton->OnClicked.AddDynamic(
this,
&UHyperTwistCoachDashboardWidget::HandleNextClosureRecoveryClicked
);
}
if (RepeatRunButton != nullptr)
{
RepeatRunButton->OnClicked.AddDynamic(this, &UHyperTwistCoachDashboardWidget::HandleRepeatRunClicked);
@ -2806,6 +2925,21 @@ void UHyperTwistCoachDashboardWidget::SyncSelectedQueueSuppressionHistoryEntry()
}
}
void UHyperTwistCoachDashboardWidget::SyncSelectedClosureRecoveryHistoryEntry()
{
if (ClosureRecoveryHistoryEntries.Num() <= 0)
{
SelectedClosureRecoveryIndex = INDEX_NONE;
return;
}
if (SelectedClosureRecoveryIndex < 0
|| SelectedClosureRecoveryIndex >= ClosureRecoveryHistoryEntries.Num())
{
SelectedClosureRecoveryIndex = ClosureRecoveryHistoryEntries.Num() - 1;
}
}
void UHyperTwistCoachDashboardWidget::SyncPreferredGuidancePreviewLane(const bool bForceAdoptPreferredLane)
{
const FHyperTwistCoachDashboardGuidanceLanePreference Preference = BuildGuidanceLanePreference();
@ -3645,6 +3779,89 @@ void UHyperTwistCoachDashboardWidget::RecordQueueSuppressionSnapshot()
SelectedQueueSuppressionIndex = QueueSuppressionHistoryEntries.Num() - 1;
}
void UHyperTwistCoachDashboardWidget::RecordClosureRecoverySnapshot()
{
const int32 HistoricalKeepCount =
CachedCoachSchedulePolicySummary.HistoricalFollowUpContinuationCount;
const int32 HistoricalSuppressCount =
CachedCoachSchedulePolicySummary.HistoricalBorderlineFollowUpSuppressionCount;
const FHyperTwistCoachSignal* ClosureRecoverySignal = CachedCoachSignals.FindByPredicate(
[](const FHyperTwistCoachSignal& Signal)
{
return Signal.SignalId == TEXT("closure_history_follow_up_continuation")
|| Signal.SignalId == TEXT("closure_history_recommended_suppression");
}
);
const bool bHasClosureSignal = ClosureRecoverySignal != nullptr;
FHyperTwistCoachDashboardClosureRecoveryEntry Entry;
Entry.RecordedAtUtc = FDateTime::UtcNow().ToIso8601();
Entry.HistoricalFollowUpContinuationCount = HistoricalKeepCount;
Entry.HistoricalBorderlineFollowUpSuppressionCount = HistoricalSuppressCount;
Entry.bFollowUpContinuationFromRecoveryHistory =
CachedCoachSchedulePolicySummary.bFollowUpContinuationFromRecoveryHistory;
Entry.bBorderlineFollowUpSuppressedByRecoveryHistory =
CachedCoachSchedulePolicySummary.bBorderlineFollowUpSuppressedByRecoveryHistory;
Entry.PreferredGuidanceLane = CachedCoachPanelState.PreferredGuidanceLane;
Entry.PreferredGuidanceSourceLabel = CachedCoachPanelState.PreferredGuidanceSourceLabel;
Entry.ClosurePolicyLine = CachedCoachSchedulePolicySummary.ClosurePolicyLine;
if (bHasClosureSignal)
{
Entry.ActiveSignalId = ClosureRecoverySignal->SignalId;
Entry.ActiveSignalHeadline = ClosureRecoverySignal->Headline;
Entry.ActiveSignalDetail = ClosureRecoverySignal->Detail;
Entry.ActiveSignalScore = ClosureRecoverySignal->Score;
Entry.bActiveSignalPromoted =
CachedCoachBrief.Signals.Num() > 0
&& CachedCoachBrief.Signals[0].SignalId == ClosureRecoverySignal->SignalId;
}
const bool bHasClosureRecoveryHistory =
HistoricalKeepCount > 0
|| HistoricalSuppressCount > 0
|| Entry.bFollowUpContinuationFromRecoveryHistory
|| Entry.bBorderlineFollowUpSuppressedByRecoveryHistory
|| bHasClosureSignal;
if (!bHasClosureRecoveryHistory || !Entry.IsStructurallyValid())
{
return;
}
const bool bMatchesLastEntry = ClosureRecoveryHistoryEntries.Num() > 0
&& ClosureRecoveryHistoryEntries.Last().HistoricalFollowUpContinuationCount
== Entry.HistoricalFollowUpContinuationCount
&& ClosureRecoveryHistoryEntries.Last().HistoricalBorderlineFollowUpSuppressionCount
== Entry.HistoricalBorderlineFollowUpSuppressionCount
&& ClosureRecoveryHistoryEntries.Last().bFollowUpContinuationFromRecoveryHistory
== Entry.bFollowUpContinuationFromRecoveryHistory
&& ClosureRecoveryHistoryEntries.Last().bBorderlineFollowUpSuppressedByRecoveryHistory
== Entry.bBorderlineFollowUpSuppressedByRecoveryHistory
&& ClosureRecoveryHistoryEntries.Last().PreferredGuidanceLane
== Entry.PreferredGuidanceLane
&& ClosureRecoveryHistoryEntries.Last().PreferredGuidanceSourceLabel
== Entry.PreferredGuidanceSourceLabel
&& ClosureRecoveryHistoryEntries.Last().ClosurePolicyLine
== Entry.ClosurePolicyLine
&& ClosureRecoveryHistoryEntries.Last().ActiveSignalId
== Entry.ActiveSignalId
&& ClosureRecoveryHistoryEntries.Last().bActiveSignalPromoted
== Entry.bActiveSignalPromoted;
if (bMatchesLastEntry)
{
return;
}
ClosureRecoveryHistoryEntries.Add(Entry);
constexpr int32 MaxRetainedClosureRecoveryEntries = 16;
if (ClosureRecoveryHistoryEntries.Num() > MaxRetainedClosureRecoveryEntries)
{
const int32 ExcessCount =
ClosureRecoveryHistoryEntries.Num() - MaxRetainedClosureRecoveryEntries;
ClosureRecoveryHistoryEntries.RemoveAt(0, ExcessCount, EAllowShrinking::No);
}
SelectedClosureRecoveryIndex = ClosureRecoveryHistoryEntries.Num() - 1;
}
void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation()
{
const bool bHasActiveRun = CachedRunState.Session.IsStructurallyValid()
@ -5393,6 +5610,60 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation()
? *ClosureRecoverySignal->Detail
: TEXT("")
);
const FHyperTwistCoachDashboardClosureRecoveryEntry* SelectedClosureRecoveryEntry =
SelectedClosureRecoveryIndex >= 0
&& SelectedClosureRecoveryIndex < ClosureRecoveryHistoryEntries.Num()
? &ClosureRecoveryHistoryEntries[SelectedClosureRecoveryIndex]
: nullptr;
const FString SelectedClosureHistoryLaneLabel =
SelectedClosureRecoveryEntry != nullptr
? (SelectedClosureRecoveryEntry->PreferredGuidanceLane
== EHyperTwistTrainingCoachGuidanceLane::FollowUp
? TEXT("follow-up")
: (SelectedClosureRecoveryEntry->PreferredGuidanceLane
== EHyperTwistTrainingCoachGuidanceLane::Recommended
? TEXT("recommended")
: TEXT("none")))
: TEXT("none");
const FString ClosureRecoveryHistoryStatusLine =
SelectedClosureRecoveryEntry != nullptr
? FString::Printf(
TEXT("Closure history: %d entries | selected %d/%d | Lane: %s | Recorded: %s"),
ClosureRecoveryHistoryEntries.Num(),
SelectedClosureRecoveryIndex + 1,
ClosureRecoveryHistoryEntries.Num(),
*SelectedClosureHistoryLaneLabel,
*SelectedClosureRecoveryEntry->RecordedAtUtc
)
: TEXT("Closure history: no retained closure-recovery shifts recorded yet.");
const FString ClosureRecoveryHistoryDetailLine =
SelectedClosureRecoveryEntry != nullptr
? FString::Printf(
TEXT("Policy: %s | keep %d | suppress %d | continuation active: %s | borderline suppression active: %s | source: %s | promoted: %s | signal: %s%s%s"),
SelectedClosureRecoveryEntry->ClosurePolicyLine.IsEmpty()
? TEXT("n/a")
: *SelectedClosureRecoveryEntry->ClosurePolicyLine,
SelectedClosureRecoveryEntry->HistoricalFollowUpContinuationCount,
SelectedClosureRecoveryEntry->HistoricalBorderlineFollowUpSuppressionCount,
SelectedClosureRecoveryEntry->bFollowUpContinuationFromRecoveryHistory
? TEXT("yes")
: TEXT("no"),
SelectedClosureRecoveryEntry->bBorderlineFollowUpSuppressedByRecoveryHistory
? TEXT("yes")
: TEXT("no"),
SelectedClosureRecoveryEntry->PreferredGuidanceSourceLabel.IsEmpty()
? TEXT("n/a")
: *SelectedClosureRecoveryEntry->PreferredGuidanceSourceLabel,
SelectedClosureRecoveryEntry->bActiveSignalPromoted ? TEXT("yes") : TEXT("no"),
SelectedClosureRecoveryEntry->ActiveSignalHeadline.IsEmpty()
? TEXT("n/a")
: *SelectedClosureRecoveryEntry->ActiveSignalHeadline,
SelectedClosureRecoveryEntry->ActiveSignalDetail.IsEmpty() ? TEXT("") : TEXT(" || detail: "),
SelectedClosureRecoveryEntry->ActiveSignalDetail.IsEmpty()
? TEXT("")
: *SelectedClosureRecoveryEntry->ActiveSignalDetail
)
: TEXT("Closure history detail: retained keep-vs-suppress shifts will accumulate here as recovery history changes the closure posture over time.");
if (ClosureRecoveryHeaderTextBlock != nullptr)
{
ClosureRecoveryHeaderTextBlock->SetText(FText::FromString(TEXT("[Closure Recovery]")));
@ -5405,6 +5676,34 @@ void UHyperTwistCoachDashboardWidget::UpdateDashboardPresentation()
{
ClosureRecoveryDetailTextBlock->SetText(FText::FromString(ClosureRecoveryDetailLine));
}
if (ClosureRecoveryHistoryHeaderTextBlock != nullptr)
{
ClosureRecoveryHistoryHeaderTextBlock->SetText(FText::FromString(TEXT("[Closure Recovery History]")));
}
if (ClosureRecoveryHistoryStatusTextBlock != nullptr)
{
ClosureRecoveryHistoryStatusTextBlock->SetText(FText::FromString(ClosureRecoveryHistoryStatusLine));
}
if (ClosureRecoveryHistoryDetailTextBlock != nullptr)
{
ClosureRecoveryHistoryDetailTextBlock->SetText(FText::FromString(ClosureRecoveryHistoryDetailLine));
}
if (PreviousClosureRecoveryButtonLabel != nullptr)
{
PreviousClosureRecoveryButtonLabel->SetText(FText::FromString(TEXT("Prev Closure")));
}
if (PreviousClosureRecoveryButton != nullptr)
{
PreviousClosureRecoveryButton->SetIsEnabled(ClosureRecoveryHistoryEntries.Num() > 1);
}
if (NextClosureRecoveryButtonLabel != nullptr)
{
NextClosureRecoveryButtonLabel->SetText(FText::FromString(TEXT("Next Closure")));
}
if (NextClosureRecoveryButton != nullptr)
{
NextClosureRecoveryButton->SetIsEnabled(ClosureRecoveryHistoryEntries.Num() > 1);
}
if (QueueRecoveryHeaderTextBlock != nullptr)
{
QueueRecoveryHeaderTextBlock->SetText(FText::FromString(TEXT("[Queue Recovery]")));

View file

@ -179,6 +179,35 @@ struct FHyperTwistCoachDashboardQueueSuppressionEntry
}
};
struct FHyperTwistCoachDashboardClosureRecoveryEntry
{
FString RecordedAtUtc;
int32 HistoricalFollowUpContinuationCount = 0;
int32 HistoricalBorderlineFollowUpSuppressionCount = 0;
bool bFollowUpContinuationFromRecoveryHistory = false;
bool bBorderlineFollowUpSuppressedByRecoveryHistory = false;
EHyperTwistTrainingCoachGuidanceLane PreferredGuidanceLane =
EHyperTwistTrainingCoachGuidanceLane::None;
FString PreferredGuidanceSourceLabel;
FString ClosurePolicyLine;
FString ActiveSignalId;
FString ActiveSignalHeadline;
FString ActiveSignalDetail;
float ActiveSignalScore = 0.0f;
bool bActiveSignalPromoted = false;
bool IsStructurallyValid() const
{
return !RecordedAtUtc.IsEmpty()
&& (HistoricalFollowUpContinuationCount > 0
|| HistoricalBorderlineFollowUpSuppressionCount > 0
|| bFollowUpContinuationFromRecoveryHistory
|| bBorderlineFollowUpSuppressedByRecoveryHistory
|| !ActiveSignalId.IsEmpty()
|| !ClosurePolicyLine.IsEmpty());
}
};
UCLASS(BlueprintType, Blueprintable)
class UNREALHYPERTWIST_API UHyperTwistCoachDashboardWidget : public UHyperTwistTrainingPanelWidget
{
@ -224,6 +253,9 @@ public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard")
int32 SelectedQueueSuppressionIndex = INDEX_NONE;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard")
int32 SelectedClosureRecoveryIndex = INDEX_NONE;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HyperTwist|Training|Coach|Dashboard")
bool bHasLiveAttemptTimer = false;
@ -317,6 +349,12 @@ public:
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard")
bool SelectNextQueueSuppressionHistoryEntry();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard")
bool SelectPreviousClosureRecoveryHistoryEntry();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard")
bool SelectNextClosureRecoveryHistoryEntry();
UFUNCTION(BlueprintCallable, Category = "HyperTwist|Training|Coach|Dashboard")
bool StartLiveAttemptTimer();
@ -593,6 +631,27 @@ protected:
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ClosureRecoveryDetailTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ClosureRecoveryHistoryHeaderTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ClosureRecoveryHistoryStatusTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> ClosureRecoveryHistoryDetailTextBlock = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> PreviousClosureRecoveryButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> PreviousClosureRecoveryButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UButton> NextClosureRecoveryButton = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> NextClosureRecoveryButtonLabel = nullptr;
UPROPERTY(Transient)
TObjectPtr<UTextBlock> QueueRecoveryHeaderTextBlock = nullptr;
@ -863,6 +922,12 @@ protected:
UFUNCTION()
void HandleNextQueueSuppressionClicked();
UFUNCTION()
void HandlePreviousClosureRecoveryClicked();
UFUNCTION()
void HandleNextClosureRecoveryClicked();
UFUNCTION()
void HandleClearRunClicked();
@ -919,6 +984,7 @@ private:
void SyncSelectedGuidancePreviewCase();
void SyncSelectedGuidanceDecisionHistoryEntry();
void SyncSelectedQueueSuppressionHistoryEntry();
void SyncSelectedClosureRecoveryHistoryEntry();
void SyncPreferredGuidancePreviewLane(bool bForceAdoptPreferredLane);
void UpdateDashboardPresentation();
void RefreshLiveAttemptClock();
@ -959,6 +1025,7 @@ private:
bool bSucceeded
);
void RecordQueueSuppressionSnapshot();
void RecordClosureRecoverySnapshot();
void RecordGuidanceOutcomeDecision(
EHyperTwistCoachDashboardOutcomeAction RecommendedAction,
EHyperTwistCoachDashboardOutcomeAction AppliedAction,
@ -1009,6 +1076,7 @@ private:
FHyperTwistCoachDashboardQueueRecoveryOutcome QueueRecoveryOutcome;
TArray<FHyperTwistCoachDashboardQueueRecoveryOutcome> QueueRecoveryOutcomeHistory;
TArray<FHyperTwistCoachDashboardQueueSuppressionEntry> QueueSuppressionHistoryEntries;
TArray<FHyperTwistCoachDashboardClosureRecoveryEntry> ClosureRecoveryHistoryEntries;
EHyperTwistCoachDashboardGuidancePreviewLane ActiveGuidancePreviewLane =
EHyperTwistCoachDashboardGuidancePreviewLane::None;
int32 SelectedGuidancePreviewCaseIndex = INDEX_NONE;