diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/StatisticsConverters.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/StatisticsConverters.cs new file mode 100644 index 0000000..64d564a --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/StatisticsConverters.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ScriptRunner.GUI.Converters; + +public class IntensityToColorConverter : IMultiValueConverter +{ + public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + if (values.Count > 0 && values[0] is int intensity) + { + return intensity switch + { + 0 => Color.Parse("#161b22"), + 1 => Color.Parse("#0e4429"), + 2 => Color.Parse("#006d32"), + 3 => Color.Parse("#26a641"), + 4 => Color.Parse("#39d353"), + _ => Color.Parse("#161b22") + }; + } + return Color.Parse("#161b22"); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class IntensityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int intensity && parameter is string paramStr && int.TryParse(paramStr, out var targetIntensity)) + { + return intensity == targetIntensity; + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class WeekToPositionConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int weekIndex) + { + return weekIndex * 14; // 12px width + 2px margin + } + return 0; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class DayToPositionConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int dayOfWeek) + { + return dayOfWeek * 14; // 12px height + 2px margin + } + return 0; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class MonthLabelConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str) + { + var parts = str.Split('|'); + return parts.Length > 0 ? parts[0] : ""; + } + return ""; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class MonthWidthConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str) + { + var parts = str.Split('|'); + if (parts.Length > 1 && int.TryParse(parts[1], out var weekCount)) + { + // Each week is 14px wide (12px cell + 2px margin) + return weekCount * 14; + } + } + return 56; // Default to 4 weeks + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class MonthPositionConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str) + { + var parts = str.Split('|'); + if (parts.Length > 1 && int.TryParse(parts[1], out var weekIndex)) + { + return weekIndex * 14; // 12px width + 2px margin + } + } + return 0; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + +public class IndexToRankConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int index) + { + return (index + 1).ToString(); + } + return "0"; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs index fefe067..f7d66a7 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs @@ -321,7 +321,7 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind case PromptType.Dropdown: var delimiterForOptions = p.GetPromptSettings("delimiter", x => x, ","); var dropdownOptions = p.GetDropdownOptions(delimiterForOptions); - var observableDropdownOptions = new ObservableCollection(dropdownOptions); + var searchable = p.GetPromptSettings("searchable", bool.Parse, false); var optionsGeneratorCommand = p.GetPromptSettings("optionsGeneratorCommand", out var optionsGeneratorCommandText) ? optionsGeneratorCommandText : null; @@ -329,15 +329,15 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind DropdownOption? selectedOption = null; if (!string.IsNullOrWhiteSpace(value)) { - selectedOption = observableDropdownOptions.FirstOrDefault(opt => opt.Value == value); - if (selectedOption == null && string.IsNullOrWhiteSpace(optionsGeneratorCommand) == false) + selectedOption = dropdownOptions.FirstOrDefault(opt => opt.Value == value); + if (selectedOption == null) { - // Add the value as a temporary option if not found and generator is available - selectedOption = new DropdownOption(value); - observableDropdownOptions.Add(selectedOption); + // Add the value as a temporary option, maybe it was available before but not anymore + selectedOption = new DropdownOption(value, value); + dropdownOptions.Add(selectedOption); } } - + var observableDropdownOptions = new ObservableCollection(dropdownOptions); Control inputControl; if (searchable) diff --git a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml index bfaa321..a0cc55d 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml @@ -152,11 +152,6 @@ - - - diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs index c97dbc9..7dc6e8c 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs @@ -49,6 +49,15 @@ public bool IsRecentListVisible private bool _isRecentListVisible; + public bool IsStatisticsVisible + { + get => _isStatisticsVisible; + set => this.RaiseAndSetIfChanged(ref _isStatisticsVisible, value); + } + + private bool _isStatisticsVisible; + + public StatisticsViewModel Statistics { get; private set; } public bool IsSideBoxVisible => _isSideBoxVisible.Value; @@ -229,6 +238,9 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider _vaultProvider = vaultProvider; this.appUpdater = new GithubUpdater(); + // Initialize Statistics ViewModel + Statistics = new StatisticsViewModel(ExecutionLog); + ExecutionLogAction? lastSelected = null; this.WhenAnyValue(x=>x.SelectedRecentExecution) @@ -245,20 +257,30 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider } }); - this.WhenAnyValue(x => x.IsScriptListVisible, x => x.IsRecentListVisible) - .Select((t1, t2) => (t1.Item1 || t1.Item2)) + this.WhenAnyValue(x => x.IsScriptListVisible, x => x.IsRecentListVisible, x => x.IsStatisticsVisible) + .Select(t => (t.Item1 || t.Item2 || t.Item3)) .ObserveOn(RxApp.MainThreadScheduler) .ToProperty(this, x => x.IsSideBoxVisible, out _isSideBoxVisible); this.WhenAnyValue(x => x.IsScriptListVisible) .Where(x => x) .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(b => IsRecentListVisible = false); + .Subscribe(b => { IsRecentListVisible = false; IsStatisticsVisible = false; }); this.WhenAnyValue(x => x.IsRecentListVisible) .Where(x => x) .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(b => IsScriptListVisible = false); + .Subscribe(b => { IsScriptListVisible = false; IsStatisticsVisible = false; }); + + this.WhenAnyValue(x => x.IsStatisticsVisible) + .Where(x => x) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(b => + { + IsScriptListVisible = false; + IsRecentListVisible = false; + Statistics.RefreshStatistics(); + }); this.WhenAnyValue(x => x.ActionFilter, x => x.Actions) .Throttle(TimeSpan.FromMilliseconds(200)) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs new file mode 100644 index 0000000..ef9cee6 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using ReactiveUI; + +namespace ScriptRunner.GUI.ViewModels; + +public class StatisticsViewModel : ReactiveObject +{ + private readonly ObservableCollection _executionLog; + + public StatisticsViewModel(ObservableCollection executionLog) + { + _executionLog = executionLog; + RefreshStatistics(); + } + + private List _heatmapDays = new(); + public List HeatmapDays + { + get => _heatmapDays; + set => this.RaiseAndSetIfChanged(ref _heatmapDays, value); + } + + private List _topActions = new(); + public List TopActions + { + get => _topActions; + set => this.RaiseAndSetIfChanged(ref _topActions, value); + } + + private List _monthLabels = new(); + public List MonthLabels + { + get => _monthLabels; + set => this.RaiseAndSetIfChanged(ref _monthLabels, value); + } + + private int _minWeek = 0; + public int MinWeek + { + get => _minWeek; + set => this.RaiseAndSetIfChanged(ref _minWeek, value); + } + + private int _maxWeek = 0; + public int MaxWeek + { + get => _maxWeek; + set => this.RaiseAndSetIfChanged(ref _maxWeek, value); + } + + public void RefreshStatistics() + { + var now = DateTime.Now; + var yearAgo = now.Date.AddYears(-1); + + // Filter execution log for the last year + var yearData = _executionLog + .Where(x => x.Timestamp >= yearAgo) + .ToList(); + + // Generate heatmap data + GenerateHeatmapData(yearData, yearAgo, now); + + // Generate top 10 actions + GenerateTopActions(yearData); + } + + private void GenerateHeatmapData(List yearData, DateTime startDate, DateTime endDate) + { + // Group by date and count executions + var executionsByDate = yearData + .GroupBy(x => x.Timestamp.Date) + .ToDictionary(x => x.Key, x => x.Count()); + + var heatmapDays = new List(); + var monthLabels = new List(); + + // Start from the first Sunday before or on the start date + var currentDate = startDate; + while (currentDate.DayOfWeek != DayOfWeek.Sunday) + { + currentDate = currentDate.AddDays(-1); + } + + var startSunday = currentDate; + string currentMonth = ""; + int currentMonthStartWeek = 0; + + while (currentDate <= endDate) + { + var count = executionsByDate.TryGetValue(currentDate, out var c) ? c : 0; + var intensity = GetIntensityLevel(count); + + // Calculate week index based on days from start Sunday + int daysSinceStart = (int)(currentDate - startSunday).TotalDays; + int weekIndex = daysSinceStart / 7; + + heatmapDays.Add(new HeatmapDay + { + Date = currentDate, + Count = count, + Intensity = intensity, + DayOfWeek = (int)currentDate.DayOfWeek, + WeekIndex = weekIndex, + ToolTip = $"{currentDate:MMM dd, yyyy}: {count} execution{(count != 1 ? "s" : "")} [Week {weekIndex}, Day {(int)currentDate.DayOfWeek}]" + }); + + // Track month changes for labels + var monthName = currentDate.ToString("MMM"); + if (monthName != currentMonth) + { + // Only add the previous month if we had one + if (!string.IsNullOrEmpty(currentMonth)) + { + int weeksInMonth = weekIndex - currentMonthStartWeek; + monthLabels.Add($"{currentMonth}|{weeksInMonth}"); + } + currentMonth = monthName; + currentMonthStartWeek = weekIndex; + } + + currentDate = currentDate.AddDays(1); + } + + // Add the final month + if (!string.IsNullOrEmpty(currentMonth)) + { + int daysSinceStart = (int)(endDate - startSunday).TotalDays; + int finalWeekIndex = daysSinceStart / 7; + int weeksInMonth = finalWeekIndex - currentMonthStartWeek + 1; + monthLabels.Add($"{currentMonth}|{weeksInMonth}"); + } + + HeatmapDays = heatmapDays; + MonthLabels = monthLabels; + + // Update min/max week for debugging + if (heatmapDays.Count > 0) + { + MinWeek = heatmapDays.Min(x => x.WeekIndex); + MaxWeek = heatmapDays.Max(x => x.WeekIndex); + } + else + { + MinWeek = 0; + MaxWeek = 0; + } + } + + private int GetIntensityLevel(int count) + { + if (count == 0) return 0; + if (count <= 2) return 1; + if (count <= 5) return 2; + if (count <= 10) return 3; + return 4; + } + + private void GenerateTopActions(List yearData) + { + var topActions = yearData + .GroupBy(x => new { x.Source, x.Name }) + .Select(g => new TopActionItem + { + ActionName = g.Key.Name, + Source = g.Key.Source, + ExecutionCount = g.Count() + }) + .OrderByDescending(x => x.ExecutionCount) + .Take(10) + .Select((item, index) => + { + item.Rank = (index + 1).ToString(); + return item; + }) + .ToList(); + + TopActions = topActions; + } +} + +public class HeatmapDay +{ + public DateTime Date { get; set; } + public int Count { get; set; } + public int Intensity { get; set; } + public int DayOfWeek { get; set; } + public int WeekIndex { get; set; } + public string ToolTip { get; set; } = ""; +} + +public class TopActionItem +{ + public string Rank { get; set; } = ""; + public string ActionName { get; set; } = ""; + public string Source { get; set; } = ""; + public int ExecutionCount { get; set; } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml index 7a2e4e6..12723b6 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml @@ -16,8 +16,18 @@ - - + + + + + + + + + + + + @@ -87,7 +97,7 @@ - + - + @@ -165,9 +175,8 @@ - - - + + @@ -222,8 +231,7 @@ - - + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml index e261db7..cb7b2d8 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml @@ -60,6 +60,9 @@ SelectedItem="{Binding SelectedRecentExecution, Mode=TwoWay}" ShowDatePicker="True"/> + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml index ece75c8..7b54641 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml @@ -24,6 +24,7 @@ +