mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat(dashboard): add trend metrics (SH-350)
This commit is contained in:
parent
3358ea5058
commit
9d7efa34a6
5 changed files with 498 additions and 1 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,47 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
[HttpGet("Trend")]
|
||||
public async Task<IActionResult> 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<IActionResult> GetVendorInsights(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<DashboardTrendBucketDTO> 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<DashboardWorkloadRowDTO> Items { get; init; } = [];
|
||||
|
|
|
|||
|
|
@ -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<DashboardTrendResponseDTO> 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<DateOnly> BuildBucketStarts(DateOnly from, DateOnly to, string granularity)
|
||||
{
|
||||
var starts = new List<DateOnly>();
|
||||
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<DateOnly> 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<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ namespace SeaHaven.Services.Interfaces
|
|||
DashboardStatsQueryDTO query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardTrendResponseDTO> GetTrendAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardTrendQueryDTO query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardVendorInsightsResponseDTO> GetVendorInsightsAsync(
|
||||
ClaimsPrincipal user,
|
||||
CancellationToken cancellationToken);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue