diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index a383bd7..54b8886 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -367,4 +367,259 @@ public class DashboardServiceTests result.Items[0].CompletionRate.Should().Be(100); result.Items[0].RescheduleRate.Should().Be(50); } + + [Fact] + public async Task GetTrendAsync_DailyBucketsClassifyByNewYorkScheduledDate() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Canceled, + ScheduledDate = new DateTime(2026, 1, 14, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 1, 15, 17, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 1, 16, 15, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 17, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 13, 20, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 1, 14), + DateTo = new DateOnly(2026, 1, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("day"); + trend.Buckets.Select(bucket => bucket.Date).Should().Equal( + new DateOnly(2026, 1, 14), new DateOnly(2026, 1, 15), new DateOnly(2026, 1, 16)); + trend.Buckets.Select(bucket => bucket.Label).Should().Equal( + "2026-01-14", "2026-01-15", "2026-01-16"); + + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Canceled.Should().Be(1); + trend.Buckets[0].Completed.Should().Be(0); + trend.Buckets[0].Overdue.Should().Be(1); + + trend.Buckets[1].Total.Should().Be(2); + trend.Buckets[1].Open.Should().Be(1); + trend.Buckets[1].Completed.Should().Be(1); + trend.Buckets[1].Overdue.Should().Be(1); + + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Open.Should().Be(1); + trend.Buckets[2].Overdue.Should().Be(1); + + trend.Buckets.Should().OnlyContain(bucket => !bucket.IsCurrent); + } + + [Fact] + public async Task GetTrendAsync_ThreeMonthRangeUsesWeeklyMondayBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 17, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 6, 16, 3, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 9, 16, 12, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 9, 16, 18, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + Range = "3m", + DateFrom = new DateOnly(2026, 6, 17), + DateTo = new DateOnly(2026, 9, 16) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Granularity.Should().Be("week"); + trend.Buckets.Should().HaveCount(14); + trend.Buckets.Select(bucket => bucket.Date.DayOfWeek) + .Should().OnlyContain(day => day == DayOfWeek.Monday); + trend.Buckets[0].Date.Should().Be(new DateOnly(2026, 6, 15)); + trend.Buckets[0].Total.Should().Be(2); + trend.Buckets[0].Open.Should().Be(2); + trend.Buckets[^1].Date.Should().Be(new DateOnly(2026, 9, 14)); + trend.Buckets[^1].Total.Should().Be(1); + trend.Buckets[^1].Completed.Should().Be(1); + } + + [Fact] + public async Task GetTrendAsync_YearFilterUsesMonthlyBuckets() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Completed, + ScheduledDate = new DateTime(2026, 3, 20, 4, 0, 0, DateTimeKind.Utc), + CompletedDate = new DateTime(2026, 3, 21, 4, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2025, 12, 31, 20, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2027, 1, 1, 3, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), new DashboardTrendQueryDTO { Year = 2026 }, CancellationToken.None); + + trend.Granularity.Should().Be("month"); + trend.Buckets.Should().HaveCount(12); + trend.Buckets[0].Label.Should().Be("2026-01-01"); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets[0].Open.Should().Be(1); + trend.Buckets[0].Overdue.Should().Be(1); + trend.Buckets[2].Label.Should().Be("2026-03-01"); + trend.Buckets[2].Total.Should().Be(1); + trend.Buckets[2].Completed.Should().Be(1); + trend.Buckets[11].Label.Should().Be("2026-12-01"); + trend.Buckets[11].Total.Should().Be(1); + trend.Buckets[11].Open.Should().Be(1); + trend.Buckets[11].Overdue.Should().Be(0); + trend.Today.Should().Be(DashboardBusinessTime.Today()); + } + + [Fact] + public async Task GetTrendAsync_MarksTodayBucketAsCurrent() + { + using var ctx = NewContext(); + var today = DashboardBusinessTime.Today(); + ctx.workOrders.Add(new WorkOrder + { + AccountId = 1, + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = today.AddDays(-2).ToDateTime(new TimeOnly(12, 0)) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = today.AddDays(-2), + DateTo = today.AddDays(2) + }; + var trend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + trend.Today.Should().Be(today); + trend.Buckets.Should().HaveCount(5); + trend.Buckets.Count(bucket => bucket.IsCurrent).Should().Be(1); + trend.Buckets.Single(bucket => bucket.IsCurrent).Date.Should().Be(today); + trend.Buckets[0].Total.Should().Be(1); + trend.Buckets.Single(bucket => bucket.Date == today).Total.Should().Be(0); + } + + [Fact] + public async Task GetTrendAsync_DispatcherRoleAndTenantScopeMatchStats() + { + using var ctx = NewContext(); + ctx.workOrders.AddRange( + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 1, + AssignTo = "dispatcher-2", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }, + new WorkOrder + { + AccountId = 2, + AssignTo = "dispatcher-1", + LifecycleStatus = Data.SeaHavenIndustries.Enums.LifecycleStatus.Scheduled, + ScheduledDate = new DateTime(2026, 2, 2, 12, 0, 0, DateTimeKind.Utc) + }); + ctx.SaveChanges(); + + var query = new DashboardTrendQueryDTO + { + DateFrom = new DateOnly(2026, 2, 2), + DateTo = new DateOnly(2026, 2, 3) + }; + + var dispatcherTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1, "dispatcher-1", "Dispatcher"), query, CancellationToken.None); + + dispatcherTrend.Buckets.Should().HaveCount(2); + dispatcherTrend.Buckets[0].Total.Should().Be(1); + dispatcherTrend.Buckets[1].Total.Should().Be(0); + + var adminTrend = await NewService(ctx).GetTrendAsync( + AccountUser(1), query, CancellationToken.None); + + adminTrend.Buckets[0].Total.Should().Be(2); + + var otherAccountTrend = await NewService(ctx).GetTrendAsync( + AccountUser(2), query, CancellationToken.None); + + otherAccountTrend.Buckets[0].Total.Should().Be(1); + } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index 055b933..5551a5d 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -110,6 +110,47 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("Trend")] + public async Task GetTrend( + [FromQuery] DashboardTrendQueryDTO query, + CancellationToken cancellationToken = default) + { + try + { + var trend = await _dashboardService.GetTrendAsync(User, query, cancellationToken); + + return Ok(new + { + granularity = trend.Granularity, + today = trend.Today, + buckets = trend.Buckets.Select(bucket => new + { + date = bucket.Date, + label = bucket.Label, + total = bucket.Total, + open = bucket.Open, + completed = bucket.Completed, + canceled = bucket.Canceled, + overdue = bucket.Overdue, + isCurrent = bucket.IsCurrent + }) + }); + } + catch (SeaHaven.Services.Exceptions.WorkOrderBoardValidationException ex) + when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + code = ex.Code, + message = ex.Message + }); + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + } + [HttpGet("VendorInsights")] public async Task GetVendorInsights(CancellationToken cancellationToken = default) { diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs index a767a03..ef5c70d 100644 --- a/SeaHaven.Services/DTOs/DashboardDTOs.cs +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -1,11 +1,36 @@ namespace SeaHaven.Services.DTOs { - public sealed class DashboardStatsQueryDTO + public class DashboardStatsQueryDTO { public DateOnly? DateFrom { get; set; } public DateOnly? DateTo { get; set; } } + public sealed class DashboardTrendQueryDTO : DashboardStatsQueryDTO + { + public string? Range { get; set; } + public int? Year { get; set; } + } + + public sealed class DashboardTrendResponseDTO + { + public string Granularity { get; init; } = "day"; + public DateOnly Today { get; init; } + public IReadOnlyList Buckets { get; init; } = []; + } + + public sealed class DashboardTrendBucketDTO + { + public DateOnly Date { get; init; } + public string Label { get; init; } = string.Empty; + public int Total { get; init; } + public int Open { get; init; } + public int Completed { get; init; } + public int Canceled { get; init; } + public int Overdue { get; init; } + public bool IsCurrent { get; init; } + } + public sealed class DashboardWorkloadResponseDTO { public IReadOnlyList Items { get; init; } = []; diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs index 757dfb3..a4bdfce 100644 --- a/SeaHaven.Services/Implementation/DashboardService.cs +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Security.Claims; +using Data.SeaHavenIndustries.Enums; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; @@ -180,6 +182,175 @@ namespace SeaHaven.Services.Implementation }; } + public async Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken) + { + var accountId = _accountResolver.ResolveAccountFilter(user); + var dispatcherId = user.IsInRole("Dispatcher") + ? user.FindFirstValue(ClaimTypes.NameIdentifier) + : null; + if (user.IsInRole("Dispatcher") && string.IsNullOrWhiteSpace(dispatcherId)) + throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required."); + + var today = DashboardBusinessTime.Today(); + var (windowFrom, windowTo, granularity) = ResolveTrendWindow(query, today); + var timezone = TimeZoneInfo.FindSystemTimeZoneById(DashboardBusinessTime.TimeZoneId); + var firstBucketStart = BucketStart(windowFrom, granularity); + var lastBucketStart = BucketStart(windowTo, granularity); + var lastBucketEnd = BucketEnd(lastBucketStart, granularity); + + var workOrders = await _dataService.GetDashboardWorkOrdersAsync( + accountId, + dispatcherId, + firstBucketStart.AddDays(-1), + lastBucketEnd.AddDays(1), + cancellationToken); + + var buckets = BuildBucketStarts(firstBucketStart, windowTo, granularity) + .Select(start => new TrendBucket(start)) + .ToList(); + var bucketStarts = buckets.Select(bucket => bucket.Start).ToList(); + + foreach (var workOrder in workOrders) + { + if (workOrder.ScheduledDate is not DateTime scheduled) + continue; + var businessDate = DateOnly.FromDateTime( + TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(scheduled, DateTimeKind.Utc), timezone)); + if (businessDate < firstBucketStart || businessDate > lastBucketEnd) + continue; + var bucket = buckets[LocateBucket(bucketStarts, businessDate)]; + var status = workOrder.LifecycleStatus + ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); + bucket.Total++; + if (status == LifecycleStatus.Completed) + bucket.Completed++; + else if (status == LifecycleStatus.Canceled) + bucket.Canceled++; + else + { + bucket.Open++; + if (businessDate < today) + bucket.Overdue++; + } + } + + DateOnly? currentStart = today < firstBucketStart || today > lastBucketEnd + ? null + : buckets[LocateBucket(bucketStarts, today)].Start; + + return new DashboardTrendResponseDTO + { + Granularity = granularity, + Today = today, + Buckets = buckets.Select(bucket => new DashboardTrendBucketDTO + { + Date = bucket.Start, + Label = bucket.Start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + Total = bucket.Total, + Open = bucket.Open, + Completed = bucket.Completed, + Canceled = bucket.Canceled, + Overdue = bucket.Overdue, + IsCurrent = bucket.Start == currentStart + }).ToList() + }; + } + + private static (DateOnly From, DateOnly To, string Granularity) ResolveTrendWindow( + DashboardTrendQueryDTO query, + DateOnly today) + { + if (query.Year is int year) + { + if (year < DateOnly.MinValue.Year || year > DateOnly.MaxValue.Year) + throw new ArgumentOutOfRangeException(nameof(query)); + return (new DateOnly(year, 1, 1), new DateOnly(year, 12, 31), "month"); + } + + if (string.Equals(query.Range, ThreeMonthRange, StringComparison.OrdinalIgnoreCase)) + { + var weeklyTo = query.DateTo ?? today; + var weeklyFrom = query.DateFrom ?? weeklyTo.AddMonths(-3).AddDays(1); + if (weeklyFrom > weeklyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (weeklyFrom, weeklyTo, "week"); + } + + if (query.DateFrom is DateOnly dailyFrom && query.DateTo is DateOnly dailyTo) + { + if (dailyFrom > dailyTo) + throw new ArgumentOutOfRangeException(nameof(query)); + return (dailyFrom, dailyTo, "day"); + } + + if (query.DateFrom.HasValue != query.DateTo.HasValue) + throw new ArgumentOutOfRangeException(nameof(query)); + + return (today, today, "day"); + } + + private static List BuildBucketStarts(DateOnly from, DateOnly to, string granularity) + { + var starts = new List(); + var current = BucketStart(from, granularity); + while (current <= to) + { + starts.Add(current); + current = granularity switch + { + "week" => current.AddDays(7), + "month" => current.AddMonths(1), + _ => current.AddDays(1) + }; + } + return starts; + } + + private static DateOnly BucketStart(DateOnly date, string granularity) => granularity switch + { + "week" => date.AddDays(-(((int)date.DayOfWeek + 6) % 7)), + "month" => new DateOnly(date.Year, date.Month, 1), + _ => date + }; + + private static DateOnly BucketEnd(DateOnly start, string granularity) => granularity switch + { + "week" => start.AddDays(6), + "month" => start.AddMonths(1).AddDays(-1), + _ => start + }; + + private static int LocateBucket(List starts, DateOnly date) + { + var low = 0; + var high = starts.Count - 1; + while (low < high) + { + var middle = (low + high + 1) / 2; + if (starts[middle] <= date) + low = middle; + else + high = middle - 1; + } + return low; + } + + private const string ThreeMonthRange = "3m"; + + private sealed class TrendBucket(DateOnly start) + { + public DateOnly Start { get; } = start; + public int Total { get; set; } + public int Open { get; set; } + public int Completed { get; set; } + public int Canceled { get; set; } + public int Overdue { get; set; } + } + public async Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken) diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs index 2f744eb..8464bad 100644 --- a/SeaHaven.Services/Interfaces/IDashboardService.cs +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -27,6 +27,11 @@ namespace SeaHaven.Services.Interfaces DashboardStatsQueryDTO query, CancellationToken cancellationToken); + Task GetTrendAsync( + ClaimsPrincipal user, + DashboardTrendQueryDTO query, + CancellationToken cancellationToken); + Task GetVendorInsightsAsync( ClaimsPrincipal user, CancellationToken cancellationToken);