From dc22000c79a419ff32324049bf7a24c4f6f4662f Mon Sep 17 00:00:00 2001 From: Tomi Eckert Date: Tue, 16 Dec 2025 21:47:49 +0100 Subject: [PATCH] add hour window --- Blueberry.Redmine/Dto/UserInfo.cs | 30 +--- Blueberry.Redmine/Dto/UserList.cs | 24 +++ Blueberry.Redmine/RedmineApiClient.cs | 203 ++++++++++++++++++++++++-- Blueberry.Redmine/RedmineManager.cs | 74 ++++++++-- Blueberry/Blueberry.csproj | 2 +- Blueberry/HoursWindow.xaml | 133 +++++++++++++++++ Blueberry/HoursWindow.xaml.cs | 193 ++++++++++++++++++++++++ Blueberry/IssueWindow.xaml | 1 + Blueberry/IssueWindow.xaml.cs | 28 +++- Blueberry/MainWindow.xaml.cs | 12 +- 10 files changed, 638 insertions(+), 62 deletions(-) create mode 100644 Blueberry.Redmine/Dto/UserList.cs create mode 100644 Blueberry/HoursWindow.xaml create mode 100644 Blueberry/HoursWindow.xaml.cs diff --git a/Blueberry.Redmine/Dto/UserInfo.cs b/Blueberry.Redmine/Dto/UserInfo.cs index 48b2ce2..561b65f 100644 --- a/Blueberry.Redmine/Dto/UserInfo.cs +++ b/Blueberry.Redmine/Dto/UserInfo.cs @@ -5,18 +5,6 @@ namespace Blueberry.Redmine.Dto { public class UserInfo { - public class CustomField - { - [JsonPropertyName("id")] - public int Id { get; set; } - - [JsonPropertyName("name")] - public string Name { get; set; } - - [JsonPropertyName("value")] - public string Value { get; set; } - } - public class Root { [JsonPropertyName("user")] @@ -39,6 +27,7 @@ namespace Blueberry.Redmine.Dto [JsonPropertyName("lastname")] public string Lastname { get; set; } + public string FullName => Lastname + " " + Firstname; [JsonPropertyName("mail")] public string Mail { get; set; } @@ -50,22 +39,7 @@ namespace Blueberry.Redmine.Dto public DateTime UpdatedOn { get; set; } [JsonPropertyName("last_login_on")] - public DateTime LastLoginOn { get; set; } - - [JsonPropertyName("passwd_changed_on")] - public DateTime PasswdChangedOn { get; set; } - - [JsonPropertyName("twofa_scheme")] - public object TwofaScheme { get; set; } - - [JsonPropertyName("api_key")] - public string ApiKey { get; set; } - - [JsonPropertyName("status")] - public int Status { get; set; } - - [JsonPropertyName("custom_fields")] - public List CustomFields { get; set; } + public DateTime? LastLoginOn { get; set; } } diff --git a/Blueberry.Redmine/Dto/UserList.cs b/Blueberry.Redmine/Dto/UserList.cs new file mode 100644 index 0000000..b3e4730 --- /dev/null +++ b/Blueberry.Redmine/Dto/UserList.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +using System.Text.Json.Serialization; + +namespace Blueberry.Redmine.Dto +{ + public class UserList + { + public class Root : IResponseList + { + [JsonPropertyName("users")] + public List Users { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("offset")] + public int Offset { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + } + } +} +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. diff --git a/Blueberry.Redmine/RedmineApiClient.cs b/Blueberry.Redmine/RedmineApiClient.cs index 2545f9e..eb277cf 100644 --- a/Blueberry.Redmine/RedmineApiClient.cs +++ b/Blueberry.Redmine/RedmineApiClient.cs @@ -1,10 +1,8 @@ using Blueberry.Redmine.Dto; using Microsoft.Extensions.Logging; -using System.Diagnostics; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using static Blueberry.Redmine.Dto.UserTime; namespace Blueberry.Redmine { @@ -88,10 +86,14 @@ namespace Blueberry.Redmine while (true) { - var path = $"{endpoint}&limit={limit}&offset={offset}"; + var path = ""; + if(endpoint.Contains('?')) + path = $"{endpoint}&limit={limit}&offset={offset}"; + else + path = $"{endpoint}?limit={limit}&offset={offset}"; var responseList = await SendRequestAsync(HttpMethod.Get, path, token: token) - ?? throw new NullReferenceException(); + ?? throw new NullReferenceException(); returnList.AddRange(itemParser(responseList)); @@ -139,7 +141,7 @@ namespace Blueberry.Redmine return fields.IssuePriorities; } - public async Task> GetOpenIssuesByAssignee(int userId, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken ? token = null) + public async Task> GetOpenIssuesByAssigneeAsync(int userId, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"issues.json?assigned_to_id={userId}&status_id=open"; @@ -149,7 +151,138 @@ namespace Blueberry.Redmine return items; } - public async Task> GetProjects(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + public async Task> GetIssuesAsync( + int? userId = null, + string? projectId = null, + int? statusId = null, + bool? isOpen = null, + DateTime? createdOn = null, + DateTime? updatedOn = null, + int limit = PAGING_LIMIT, + IProgress<(int, int)>? progress = null, + CancellationToken? token = null) + { + // Start with the base endpoint + // We use a List to build parameters cleanly to avoid formatting errors + var queryParams = new List(); + + // 1. Handle User ID + if (userId != null) + queryParams.Add($"assigned_to_id={userId}"); + + // 2. Handle Project ID + if (!string.IsNullOrEmpty(projectId)) + queryParams.Add($"project_id={projectId}"); + + // 3. Handle Status (Prioritize explicit ID, then isOpen flag, then default to open) + if (statusId != null) + { + queryParams.Add($"status_id={statusId}"); + } + else if (isOpen != null) + { + queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}"); + } + else + { + // Default behavior if neither is specified (preserves your original logic) + queryParams.Add("status_id=open"); + } + + // 4. Handle Dates (Using >= operator for "Since") + if (createdOn != null) + { + // %3E%3D is ">=" + queryParams.Add($"created_on=%3E%3D{createdOn.Value:yyyy-MM-ddTHH:mm:ssZ}"); + } + + if (updatedOn != null) + { + queryParams.Add($"updated_on=%3E%3D{updatedOn.Value:yyyy-MM-ddTHH:mm:ssZ}"); + } + + // Join the path with the query string + var queryString = string.Join("&", queryParams); + var path = $"issues.json?{queryString}"; + + return await SendRequestWithPagingAsync( + HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token); + } + + public async Task> GetIssuesAsync( + int? userId = null, + string? projectId = null, + int? statusId = null, + bool? isOpen = null, + // Changed single dates to From/To pairs + DateTime? createdFrom = null, + DateTime? createdTo = null, + DateTime? updatedFrom = null, + DateTime? updatedTo = null, + int limit = PAGING_LIMIT, + IProgress<(int, int)>? progress = null, + CancellationToken? token = null) + { + var queryParams = new List(); + + // 1. Basic Filters + if (userId != null) queryParams.Add($"assigned_to_id={userId}"); + if (!string.IsNullOrEmpty(projectId)) queryParams.Add($"project_id={projectId}"); + + // 2. Status Logic + if (statusId != null) + { + queryParams.Add($"status_id={statusId}"); + } + else if (isOpen != null) + { + queryParams.Add($"status_id={(isOpen.Value ? "open" : "closed")}"); + } + else + { + queryParams.Add("status_id=open"); + } + + // 3. Date Filter Logic (Helper function used below) + string? createdFilter = BuildDateFilter("created_on", createdFrom, createdTo); + if (createdFilter != null) queryParams.Add(createdFilter); + + string? updatedFilter = BuildDateFilter("updated_on", updatedFrom, updatedTo); + if (updatedFilter != null) queryParams.Add(updatedFilter); + + // 4. Construct URL + var queryString = string.Join("&", queryParams); + var path = $"issues.json?{queryString}"; + + return await SendRequestWithPagingAsync( + HttpMethod.Get, path, limit, (x) => x.Issues, progress, token: token); + } + + // Helper method to determine the correct Redmine operator + private string? BuildDateFilter(string paramName, DateTime? from, DateTime? to) + { + string format = "yyyy-MM-ddTHH:mm:ssZ"; // ISO 8601 + + if (from.HasValue && to.HasValue) + { + // Range: ">=DATE" (URL encoded as %3E%3D) + return $"{paramName}=%3E%3D{from.Value.ToString(format)}"; + } + else if (to.HasValue) + { + // Before: "<=DATE" (URL encoded as %3C%3D) + return $"{paramName}=%3C%3D{to.Value.ToString(format)}"; + } + + return null; + } + + public async Task> GetProjectsAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"projects.json"; @@ -159,7 +292,7 @@ namespace Blueberry.Redmine return items; } - public async Task> GetTrackersForProject(string projectId, CancellationToken? token = null) + public async Task> GetTrackersForProjectAsync(string projectId, CancellationToken? token = null) { var path = $"projects/{projectId}.json?include=trackers"; @@ -169,7 +302,7 @@ namespace Blueberry.Redmine return trackers.Project.Trackers; } - public async Task GetTotalTimeForUser(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + public async Task GetTotalTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var sText = start.ToString("yyyy-MM-dd"); var eText = end.ToString("yyyy-MM-dd"); @@ -177,14 +310,24 @@ namespace Blueberry.Redmine var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}"; - var timedata = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x)=> x.TimeEntries, progress, token: token); + var timedata = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token); var hours = timedata.Sum(x => x.Hours); return hours; } + public async Task> GetTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var sText = start.ToString("yyyy-MM-dd"); + var eText = end.ToString("yyyy-MM-dd"); - public async Task GetIssue(int issueId, CancellationToken? token = null) + var path = $"time_entries.json?from={sText}&to={eText}&user_id={userId}"; + + + return await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.TimeEntries, progress, token: token); + } + + public async Task GetIssueAsync(int issueId, CancellationToken? token = null) { var path = $"issues/{issueId}.json?include=journals"; @@ -194,7 +337,7 @@ namespace Blueberry.Redmine return issue.Issue; } - public async Task GetSimpleIssue(int issueId, CancellationToken? token = null) + public async Task GetSimpleIssueAsync(int issueId, CancellationToken? token = null) { var path = $"issues/{issueId}.json?include=journals"; @@ -217,7 +360,14 @@ namespace Blueberry.Redmine return user.User; } - public async Task SetIssueStatus(int issueId, int statusId, CancellationToken? token = null) + public async Task> GetUsersAsync(int limit = PAGING_LIMIT, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + var path = "users.json"; + + return await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x) => x.Users, progress, token); + } + + public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null) { var path = $"issues/{issueId}.json"; @@ -232,14 +382,39 @@ namespace Blueberry.Redmine await SendRequestAsync(HttpMethod.Put, path, payload, token: token); } - public async Task> GetTimeOnIssue(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + public async Task AddCommentToIssueAsync(int issueId, string comment, bool isPrivate, CancellationToken? token = null) + { + var path = $"issues/{issueId}.json"; + + var payload = new + { + issue = new + { + notes = comment + } + }; + var privatePayload = new + { + issue = new + { + private_notes = comment + } + }; + + if(isPrivate) + await SendRequestAsync(HttpMethod.Put, path, privatePayload, token: token); + else + await SendRequestAsync(HttpMethod.Put, path, payload, token: token); + } + + public async Task> GetTimeOnIssueAsync(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var path = $"time_entries.json?issue_id={issueId}"; var times = await SendRequestWithPagingAsync(HttpMethod.Get, path, limit, (x)=>x.TimeEntries, progress, token: token); return times; } - public async Task CreateNewIssue(int projectId, int trackerId, string subject, string description, + public async Task CreateNewIssueAsync(int projectId, int trackerId, string subject, string description, double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null) { var path = "issues.json"; diff --git a/Blueberry.Redmine/RedmineManager.cs b/Blueberry.Redmine/RedmineManager.cs index b7a0f75..dcf101e 100644 --- a/Blueberry.Redmine/RedmineManager.cs +++ b/Blueberry.Redmine/RedmineManager.cs @@ -5,7 +5,7 @@ namespace Blueberry.Redmine { public class RedmineManager { - private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(1); + private readonly TimeSpan DEFAULT_CACHE_DURATION = TimeSpan.FromHours(3); private readonly RedmineConfig _config; private readonly ILogger _logger; private readonly RedmineApiClient _apiClient; @@ -13,6 +13,7 @@ namespace Blueberry.Redmine private readonly RedmineCache _priorityCache; private readonly RedmineCache _customFieldCache; private readonly RedmineCache _projectCache; + private readonly RedmineCache _userCache; public RedmineManager(RedmineConfig config, HttpClient client, ILoggerFactory loggerFactory) { @@ -31,6 +32,9 @@ namespace Blueberry.Redmine _projectCache = new RedmineCache( DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}Projects.json"); + _userCache = new RedmineCache( + DEFAULT_CACHE_DURATION, loggerFactory.CreateLogger>(), cacheFilePath: $"{_config.CacheFilePath}Users.json"); + _logger = loggerFactory.CreateLogger(); _logger.LogDebug("Initialized caches"); } @@ -87,11 +91,22 @@ namespace Blueberry.Redmine { return await _projectCache.GetItemsAsync(); } - var projects = await _apiClient.GetProjects(limit, progress, token); + var projects = await _apiClient.GetProjectsAsync(limit, progress, token); await _projectCache.RefreshCacheAsync(projects); return projects; } + public async Task> GetUsersAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + if (_userCache.IsCacheValid()) + { + return await _userCache.GetItemsAsync(); + } + var users = await _apiClient.GetUsersAsync(limit, progress, token); + await _userCache.RefreshCacheAsync(users); + return users; + } + public async Task GetCurrentUserAsync(CancellationToken? token = null) { var user = await _apiClient.GetUserAsync(token: token); @@ -104,42 +119,74 @@ namespace Blueberry.Redmine return user; } - public async Task> GetCurrentUserIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + public async Task> GetCurrentUserOpenIssuesAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var user = await GetCurrentUserAsync(token); - return await _apiClient.GetOpenIssuesByAssignee(user.Id, limit, progress, token); + return await _apiClient.GetOpenIssuesByAssigneeAsync(user.Id, limit, progress, token); + } + + public async Task> GetUserOpenIssuesAsync(int userId, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + return await _apiClient.GetOpenIssuesByAssigneeAsync(userId, limit, progress, token); + } + + public async Task> GetIssuesAsync( + int? userId = null, + string? projectId = null, + int? statusId = null, + bool? isOpen = null, + // Changed single dates to From/To pairs + DateTime? createdFrom = null, + DateTime? createdTo = null, + DateTime? updatedFrom = null, + DateTime? updatedTo = null, + int limit = 50, + IProgress<(int, int)>? progress = null, + CancellationToken? token = null) + { + return await _apiClient.GetIssuesAsync(userId, projectId, statusId, isOpen, createdFrom, createdTo, updatedFrom, updatedTo, limit, progress, token); + } + + public async Task AddComment(int issueId, string comment, bool isPrivate = false, CancellationToken? token = null) + { + await _apiClient.AddCommentToIssueAsync(issueId, comment, isPrivate, token); } public async Task GetCurrentUserTimeAsync(DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { var user = await GetCurrentUserAsync(token); - return await _apiClient.GetTotalTimeForUser(user.Id, start, end, limit, progress, token); + return await _apiClient.GetTotalTimeForUserAsync(user.Id, start, end, limit, progress, token); + } + + public async Task GetUserTimeAsync(int userId, DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + return await _apiClient.GetTotalTimeForUserAsync(userId, start, end, limit, progress, token); } public async Task GetIssueAsync(int issueId, CancellationToken? token = null) { - return await _apiClient.GetIssue(issueId, token); + return await _apiClient.GetIssueAsync(issueId, token); } public async Task GetSimpleIssueAsync(int issueId, CancellationToken? token = null) { - return await _apiClient.GetSimpleIssue(issueId, token); + return await _apiClient.GetSimpleIssueAsync(issueId, token); } public async Task> GetProjectTrackersAsync(int projectId, CancellationToken? token = null) { - return await _apiClient.GetTrackersForProject(projectId.ToString(), token); + return await _apiClient.GetTrackersForProjectAsync(projectId.ToString(), token); } public async Task> GetTimeOnIssue(int issueId, int limit = 25, IProgress<(int, int)>? progress = null, CancellationToken? token = null) { - var result = await _apiClient.GetTimeOnIssue(issueId, limit, progress, token); + var result = await _apiClient.GetTimeOnIssueAsync(issueId, limit, progress, token); return result; } public async Task SetIssueStatusAsync(int issueId, int statusId, CancellationToken? token = null) { - await _apiClient.SetIssueStatus(issueId, statusId, token); + await _apiClient.SetIssueStatusAsync(issueId, statusId, token); } public async Task LogTimeAsync(int issueId, double hours, string comments, DateTime? date = null, int? activityId = null, CancellationToken? token = null) @@ -150,7 +197,7 @@ namespace Blueberry.Redmine public async Task CreateIssueAsync(int projectId, int trackerId, string subject, string description, double estimatedHours, int priorityId, int? assigneeId = null, int? parentIssueId = null, CancellationToken? token = null) { - return await _apiClient.CreateNewIssue(projectId, trackerId, subject, description, estimatedHours, priorityId, assigneeId, parentIssueId, token); + return await _apiClient.CreateNewIssueAsync(projectId, trackerId, subject, description, estimatedHours, priorityId, assigneeId, parentIssueId, token); } public async Task GetCurrentUserTimeTodayAsync(int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) @@ -169,5 +216,10 @@ namespace Blueberry.Redmine var end = start.AddMonths(1).AddDays(-1); return await GetCurrentUserTimeAsync(start, end, limit, progress, token); } + + public async Task> GetTimeForUserAsync(int userId, DateTime start, DateTime end, int limit = 50, IProgress<(int, int)>? progress = null, CancellationToken? token = null) + { + return await _apiClient.GetTimeForUserAsync(userId, start, end, limit, progress, token); + } } } diff --git a/Blueberry/Blueberry.csproj b/Blueberry/Blueberry.csproj index 817ab66..6018944 100644 --- a/Blueberry/Blueberry.csproj +++ b/Blueberry/Blueberry.csproj @@ -1,7 +1,7 @@  - WinExe + Exe net8.0-windows10.0.17763.0 enable enable diff --git a/Blueberry/HoursWindow.xaml b/Blueberry/HoursWindow.xaml new file mode 100644 index 0000000..80bdd89 --- /dev/null +++ b/Blueberry/HoursWindow.xaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Blueberry/HoursWindow.xaml.cs b/Blueberry/HoursWindow.xaml.cs new file mode 100644 index 0000000..a688e0a --- /dev/null +++ b/Blueberry/HoursWindow.xaml.cs @@ -0,0 +1,193 @@ +using Blueberry.Redmine; +using Blueberry.Redmine.Dto; +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using System.Threading.Tasks; +using System.Windows; +using Wpf.Ui.Controls; + +namespace Blueberry +{ + /// + /// Interaction logic for HoursWindow.xaml + /// + public partial class HoursWindow : FluentWindow + { + private readonly List _users = []; + private readonly RedmineManager _manager; + private readonly RedmineConfig _config; + private readonly ConcurrentDictionary _issueNames = []; + + public ObservableCollection Hours { get; set; } = []; + + public HoursWindow(RedmineManager manager, RedmineConfig config) + { + InitializeComponent(); + DataContext = this; + _manager = manager; + _config = config; + } + + private async void FluentWindow_Loaded(object sender, System.Windows.RoutedEventArgs e) + { + var u = await _manager.GetUsersAsync(); + var current = await _manager.GetCurrentUserAsync(); + hoursProgress.Visibility = Visibility.Hidden; + _users.Clear(); + _users.AddRange(u); + userComboBox.Items.Clear(); + foreach (var user in u) + userComboBox.Items.Add(user); + + userComboBox.SelectedItem = current; + } + + private void dateButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + calendarFlyout.IsOpen = true; + } + + private async void searchButton_Click(object sender, System.Windows.RoutedEventArgs e) + { + var user = userComboBox.SelectedItem as UserInfo.User; + if(user is null) + return; + + var selectedDates = userCalendar.SelectedDates; + if (selectedDates.Count == 0) + return; + + ConcurrentBag hours = []; + Task[] tasks = new Task[selectedDates.Count]; + Hours.Clear(); + hoursProgress.Visibility= Visibility.Visible; + + for (int i = 0; i < selectedDates.Count; i++) + { + var date = selectedDates[i]; + tasks[i] = Task.Run(async () => + { + var h = await _manager.GetTimeForUserAsync(user.Id, date, date); + foreach (var item in h) + { + var dh = new DisplayHours() + { + ProjectName = item.Project.Name, + IssueName = await GetIssueNameAsync(item.Issue.Id), + IssueId = item.Issue.Id.ToString(), + Date = date, + CreatedOn = item.CreatedOn, + Hours = item.Hours.ToString(), + Comments = item.Comments, + IsSeparator = false + }; + hours.Add(dh); + } + }); + await Task.Delay(10); + } + + await Task.WhenAll(tasks); + + var newTickets = await _manager.GetIssuesAsync(user.Id, createdFrom: selectedDates.First(), createdTo: selectedDates.Last()); + var closedTickets = await _manager.GetIssuesAsync(user.Id, isOpen: false, updatedFrom: selectedDates.First(), updatedTo: selectedDates.Last()); + var currentTickets = await _manager.GetUserOpenIssuesAsync(user.Id); + + var total = hours.Sum(x => double.Parse(x.Hours)); + totalHoursTextBlock.Text = total.ToString(); + avgDayTextBlock.Text = Math.Round(total / selectedDates.Count, 2).ToString(); + + int workingDays = 0; + foreach (var day in selectedDates) + { + if(day.DayOfWeek != DayOfWeek.Saturday && + day.DayOfWeek != DayOfWeek.Sunday) + workingDays++; + } + avgWorkdayTextBlock.Text = Math.Round(total / workingDays, 2).ToString(); + + newTicketsTextBlock.Text = newTickets.Count.ToString(); + closedTicketsTextBlock.Text = closedTickets.Count.ToString(); + + minTicketAgeTextBlock.Text = Math.Round(currentTickets.Min(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap"; + maxTicketAgeTextBlock.Text = Math.Round(currentTickets.Max(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap"; + avgTicketAgeTextBlock.Text = Math.Round(currentTickets.Average(x => (DateTime.Now - x.CreatedOn).TotalDays), 2) + " nap"; + var ages = currentTickets.Select(x => (DateTime.Now - x.CreatedOn).TotalDays).Order().ToList(); + medianTicketAgeTextBlock.Text = Math.Round(ages[ages.Count / 2], 2) + " nap"; + + var orderedHours = hours.OrderByDescending(h => h.CreatedOn).OrderBy(h => h.Date).ToList(); + var previousDate = DateTime.MinValue; + for (int i = 0; i < orderedHours.Count; i++) + { + if(orderedHours[i].Date.Date > previousDate) + { + total = orderedHours.Where(x => x.Date.Date == orderedHours[i].Date.Date).Sum(x => double.Parse(x.Hours)); + var dh = new DisplayHours() + { + IsSeparator = true, + SeparatorText = orderedHours[i].Date.ToString("yyyy-MM-dd") + " | Összesen: " + total + " óra" + }; + orderedHours.Insert(i, dh); + previousDate = orderedHours[i+1].Date.Date; + } + } + + hoursProgress.Visibility = Visibility.Hidden; + + foreach (var item in orderedHours) + Hours.Add(item); + } + + private async Task GetIssueNameAsync(int issueId) + { + try + { + if (_issueNames.ContainsKey(issueId)) + return _issueNames[issueId]; + + var name = (await _manager.GetSimpleIssueAsync(issueId)).Subject; + _issueNames.TryAdd(issueId, name); + return name; + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + Console.WriteLine(issueId); + throw; + } + } + + public class DisplayHours + { + public string ProjectName { get; set; } = ""; + public string IssueName { get; set; } = ""; + public string IssueId { get; set; } = ""; + public DateTime Date { get; set; } + public DateTime CreatedOn { get; set; } + public string Hours { get; set; } = ""; + public string Comments { get; set; } = ""; + public string SeparatorText { get; set; } = ""; + public bool IsSeparator { get; set; } + public Visibility CardVisibility => IsSeparator ? Visibility.Visible : Visibility.Hidden; + public Visibility ButtonVisibility => IsSeparator ? Visibility.Hidden : Visibility.Visible; + } + + private async void openIssueButton_Click(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement button && button.DataContext is DisplayHours item) + { + var issueId = int.Parse(item.IssueId); + + try + { + var issue = await _manager.GetIssueAsync(issueId); + var issueWindow = new IssueWindow(issue, _manager, _config); + issueWindow.Show(); + } + catch (Exception) + { + } + } + } + } +} diff --git a/Blueberry/IssueWindow.xaml b/Blueberry/IssueWindow.xaml index 037f223..b44c759 100644 --- a/Blueberry/IssueWindow.xaml +++ b/Blueberry/IssueWindow.xaml @@ -8,6 +8,7 @@ mc:Ignorable="d" d:DataContext="{d:DesignInstance local:IssueWindow}" Loaded="FluentWindow_Loaded" + Closing="FluentWindow_Closing" Title="IssueWindow" Height="500" Width="900"> diff --git a/Blueberry/IssueWindow.xaml.cs b/Blueberry/IssueWindow.xaml.cs index 4e53425..7b4aad7 100644 --- a/Blueberry/IssueWindow.xaml.cs +++ b/Blueberry/IssueWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows; using System.Windows.Media; +using Windows.Networking.NetworkOperators; using Wpf.Ui.Controls; namespace Blueberry @@ -17,6 +18,7 @@ namespace Blueberry private readonly RedmineManager _manager; private readonly RedmineConfig _config; private readonly List _journalDisplays = []; + private CancellationTokenSource _tokenSource = new(); public ObservableCollection Journals { get; set; } = []; public IssueWindow(DetailedIssue.Issue issue, RedmineManager manager, RedmineConfig config) @@ -37,7 +39,12 @@ namespace Blueberry iUpdatedTextBox.Text = _issue.UpdatedOn.ToString("yyyy-MM-dd"); iSpentTimeTextBox.Text = _issue.SpentHours.ToString(); journalProgressRing.Visibility = Visibility.Visible; - var hours = await _manager.GetTimeOnIssue(_issue.Id); + + List hours = []; + try + { + hours = await _manager.GetTimeOnIssue(_issue.Id, progress: UpdateProgress(), token: _tokenSource.Token); + } catch { } _journalDisplays.AddRange(await ProcessJournal(_issue.Journals, hours)); if(!_journalDisplays.Any(x=>!x.IsData)) detailsToggleSwitch.IsChecked = true; @@ -45,6 +52,20 @@ namespace Blueberry journalProgressRing.Visibility = Visibility.Hidden; } + private IProgress<(int, int)> UpdateProgress() + { + var p = new Progress<(int current, int total)>((x) => + { + Dispatcher.Invoke(() => + { + journalProgressRing.IsIndeterminate = false; + var percent = (int)Math.Round((double)x.current / x.total * 100); + journalProgressRing.Progress = percent; + }); + }); + return p; + } + private async Task LoadJournal() { var showDetails = detailsToggleSwitch.IsChecked ?? true; @@ -72,6 +93,11 @@ namespace Blueberry }; Process.Start(psi); } + + private void FluentWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + _tokenSource.Cancel(); + } } public partial class IssueWindow diff --git a/Blueberry/MainWindow.xaml.cs b/Blueberry/MainWindow.xaml.cs index dc0d974..18c669b 100644 --- a/Blueberry/MainWindow.xaml.cs +++ b/Blueberry/MainWindow.xaml.cs @@ -368,10 +368,8 @@ namespace BlueMine private async void hoursButton_Click(object sender, RoutedEventArgs e) { - await new Wpf.Ui.Controls.MessageBox - { - Title = "Under construction" - }.ShowDialogAsync(); + var hoursWindow = new HoursWindow(_manager, _config); + hoursWindow.Show(); } private void updateButton_Click(object sender, RoutedEventArgs e) @@ -487,7 +485,7 @@ namespace BlueMine foreach (var issueId in Constants.StaticTickets) _issues.Add(await _manager.GetSimpleIssueAsync(issueId)); - _issues.AddRange(await _manager.GetCurrentUserIssuesAsync(progress: UpdateProgress("Jegyek letöltése:"))); + _issues.AddRange(await _manager.GetCurrentUserOpenIssuesAsync(progress: UpdateProgress("Jegyek letöltése:"))); progressBar.Value = 0; progressRing.Visibility = Visibility.Hidden; FilterIssues(); @@ -528,7 +526,7 @@ namespace BlueMine statusTextBlock.Text = $"Kapcsolódás Redminehoz..."; int maxRetries = 3; - int timeoutSeconds = 1; // Force kill after 5s + int timeoutSeconds = 3; // Force kill after 5s for (int i = 0; i < maxRetries; i++) { @@ -555,7 +553,7 @@ namespace BlueMine statusTextBlock.Text = $"Kapcsolódási hiba. Újrapróbálkozás: {i + 1}/{maxRetries}"; // Wait 1 second before retrying - await Task.Delay(1000); + await Task.Delay(5000); } // All attempts failed