diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 71c4d94..b665f2c 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -220,13 +220,26 @@ namespace SeaHaven.DataServices.Helpers && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled); + /// + /// Narrows to rows whose Schedule On date falls in the range, or, for + /// week-only rows, whose Target Week overlaps it: the same date the + /// weekly board groups by (SH-391). Rows with no date never match a + /// range; they are added only when + /// is set, which callers send for searches with no date range selected. + /// Completed and canceled dateless rows stay out, matching + /// . + /// public static IQueryable ApplyDateRangeFilter( IQueryable query, DateOnly dateFrom, - DateOnly dateTo) + DateOnly dateTo, + bool includeDateless = false) { var fromDate = dateFrom.ToDateTime(TimeOnly.MinValue).Date; var toDate = dateTo.ToDateTime(TimeOnly.MinValue).Date; + // A target week (stored as its Monday) overlaps the range when it + // starts by the range end and its Sunday is on or after the range start. + var earliestWeekStart = dateFrom.DayNumber >= 6 ? dateFrom.AddDays(-6) : dateFrom; return query.Where(w => (w.ScheduledDate != null @@ -234,9 +247,10 @@ namespace SeaHaven.DataServices.Helpers && w.ScheduledDate.Value.Date <= toDate) || (w.ScheduleWeekOnly == true && w.TargetWeek != null - && w.TargetWeek >= dateFrom + && w.TargetWeek >= earliestWeekStart && w.TargetWeek <= dateTo) - || (w.ScheduledDate == null + || (includeDateless + && w.ScheduledDate == null && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled)); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs index eeb28c5..74b5fb2 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderAdvancedSearchDataService.cs @@ -22,7 +22,8 @@ namespace SeaHaven.DataServices.Implementation if (query.UnscheduledOnly) baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery); else - baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter(baseQuery, query.DateFrom, query.DateTo); + baseQuery = WorkOrderBoardQueryFilters.ApplyDateRangeFilter( + baseQuery, query.DateFrom, query.DateTo, query.IncludeDateless); baseQuery = WorkOrderBoardQueryFilters.ApplySiteFilter(baseQuery, query.Sites); baseQuery = WorkOrderBoardQueryFilters.ApplyRegionFilter(baseQuery, query.Regions); baseQuery = WorkOrderBoardQueryFilters.ApplyTypeFilter(baseQuery, query.Types, query.Overdue); diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs index 2c0a627..4ee175a 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderAdvancedSearchModels.cs @@ -32,7 +32,8 @@ namespace SeaHaven.DataServices.Interfaces int PageSize, string SortBy, string SortDir, - int? AccountId = null); + int? AccountId = null, + bool IncludeDateless = false); public record WorkOrderAdvancedSearchResult( IReadOnlyList Rows, diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 8f2c9df..38b4c24 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -87,6 +87,14 @@ namespace SeaHaven.Services.DTOs /// filter; every other facet still applies. /// public bool UnscheduledOnly { get; set; } + /// + /// When true, rows with no date (no Schedule On) are returned alongside + /// the in-range rows. Sent for searches with no date range selected; an + /// explicit range leaves it unset or false so the range strictly narrows + /// (SH-391). When it is omitted, clients that predate the flag keep their + /// undated rows: see . + /// + public bool? IncludeDateless { get; set; } public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } diff --git a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs index 48a8f48..7cf378e 100644 --- a/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs +++ b/SeaHaven.Services/Helpers/WorkOrderSearchDateRangeResolver.cs @@ -15,6 +15,36 @@ namespace SeaHaven.Services.Helpers public static class WorkOrderSearchDateRangeResolver { + // Widest windows the frontend sends for "every week": the no-range search + // (ADVANCED_SEARCH_ALL_WEEKS_FROM/TO, 2000-01-01..2099-12-31) and the pinned + // Unassigned queue (UNASSIGNED_QUEUE_DATE_FROM/TO, 1970-01-01..2099-12-31). + private static readonly DateOnly AllWeeksFrom = new(2000, 1, 1); + private static readonly DateOnly AllWeeksTo = new(2099, 12, 31); + + /// + /// Whether a board search adds open undated rows to its date range (SH-391). + /// An explicit wins. When it is omitted, + /// as by clients that predate the flag, a search with no date input or with + /// an all-weeks Custom window still returns undated rows, as before SH-391: + /// those requests mean "no range selected". Any other range is strict. + /// + public static bool ResolveIncludeDateless( + bool? includeDateless, + WorkOrderAdvancedSearchDatePreset? preset, + DateOnly? dateFrom, + DateOnly? dateTo) + { + if (includeDateless.HasValue) + return includeDateless.Value; + + if (!preset.HasValue && !dateFrom.HasValue && !dateTo.HasValue) + return true; + + return preset == WorkOrderAdvancedSearchDatePreset.Custom + && dateFrom <= AllWeeksFrom + && dateTo >= AllWeeksTo; + } + public static WorkOrderSearchDateRange Resolve( WorkOrderAdvancedSearchDatePreset? preset, DateOnly? dateFrom, diff --git a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs index fa97dc5..39f63dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs @@ -72,7 +72,12 @@ namespace SeaHaven.Services.Implementation pageSize, sortBy, query.SortDir ?? "asc", - accountId); + accountId, + WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + query.IncludeDateless, + query.DatePreset, + query.DateFrom, + query.DateTo)); var result = await _searchDataService.SearchAsync(dataQuery); var utcNow = DateTime.UtcNow; diff --git a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs new file mode 100644 index 0000000..f6d61b2 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +/// +/// SH-391: an Advanced Filters date range narrows GET /board/search to rows +/// whose Schedule On (or week-only Target Week) is in the range. Rows with no +/// date come back only when the caller asks for them (no range selected). +/// +public class WorkOrderAdvancedSearchDateRangeTests +{ + private static readonly DateOnly RangeFrom = new(2026, 9, 21); + private static readonly DateOnly RangeTo = new(2026, 9, 25); + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrderAdvancedSearchService CreateService(ApplicationDbContext context) + => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); + + private static WorkOrder Wo( + int id, + DateTime? scheduled, + string? assignTo = null, + LifecycleStatus status = LifecycleStatus.Scheduled, + DateOnly? targetWeek = null, + int? accountId = null) + => new() + { + Id = id, + InternalWONumber = $"1000000000{id}", + AssignTo = assignTo, + ScheduledDate = scheduled, + ScheduleWeekOnly = targetWeek.HasValue, + TargetWeek = targetWeek, + LifecycleStatus = status, + AccountId = accountId, + istemplate = false + }; + + private static WorkOrder NotWeekOnly(WorkOrder wo) + { + wo.ScheduleWeekOnly = false; + return wo; + } + + private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new() + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" }, + IncludeDateless = includeDateless, + PageSize = 50 + }; + + [Fact] + public async Task SearchAsync_UnassignedWithRange_ReturnsOnlyUnassignedRowsScheduledInRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, new DateTime(2026, 9, 24), assignTo: "user-a"), + Wo(5, new DateTime(2026, 10, 2))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_RangeWithoutDispatcherFilter_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 22), assignTo: "user-a"), + Wo(2, null, assignTo: "user-a", status: LifecycleStatus.Incomplete), + Wo(3, new DateTime(2026, 9, 14), assignTo: "user-a", status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = RangeFrom, + DateTo = RangeTo + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Fact] + public async Task SearchAsync_WeekOnlyRow_MatchesRangeThatOverlapsItsTargetWeek() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, null, targetWeek: new DateOnly(2026, 9, 21)), + Wo(2, null, targetWeek: new DateOnly(2026, 9, 14)), + Wo(3, null, targetWeek: new DateOnly(2026, 9, 28)), + // Week-only with a leftover date outside the range: the week decides, + // as on the board page (SH-391). + Wo(4, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)), + // Not week-only: its date decides, so a target week does not bring it in. + NotWeekOnly(Wo(5, new DateTime(2026, 10, 2), targetWeek: new DateOnly(2026, 9, 21)))); + await context.SaveChangesAsync(); + + // Wed–Fri of the week of 9/21: the week-only row for that week overlaps it. + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2026, 9, 23), + DateTo = RangeTo, + Dispatchers = new List { "__unassigned__" } + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 4 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_IncludeDateless_AddsOpenUndatedRowsToTheRange() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23)), + Wo(2, new DateTime(2026, 8, 4)), + Wo(3, null, status: LifecycleStatus.Incomplete), + Wo(4, null, status: LifecycleStatus.Completed), + Wo(5, null, status: LifecycleStatus.Canceled)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + // Payloads the frontend sent before includeDateless existed. They must keep + // their undated rows so this backend can deploy ahead of the client. + [Theory] + [InlineData(2000)] // no range selected: ADVANCED_SEARCH_ALL_WEEKS_FROM + [InlineData(1970)] // pinned Unassigned queue: UNASSIGNED_QUEUE_DATE_FROM + public async Task SearchAsync_AllWeeksWindowWithoutFlag_KeepsOpenUndatedRows(int fromYear) + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, null, status: LifecycleStatus.Completed)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(fromYear, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + Dispatchers = new List { "__unassigned__" }, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_NoDateInputWithoutFlag_KeepsOpenUndatedRows() + { + // The WO# duplicate lookup sends only the search text and paging. + await using var context = CreateContext(); + var thisWeek = WorkOrderSearchDateRangeResolver.GetIsoWeekStart(DateOnly.FromDateTime(DateTime.UtcNow)); + context.workOrders.AddRange( + Wo(1, thisWeek.ToDateTime(TimeOnly.MinValue)), + Wo(2, null, status: LifecycleStatus.Incomplete), + Wo(3, thisWeek.AddDays(-7).ToDateTime(TimeOnly.MinValue))); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync( + new WorkOrderAdvancedSearchQueryDto { PageSize = 100 }, + WorkOrderAccountTestHelpers.OrgWideAdmin(), + null); + + Assert.Equal(new[] { 1, 2 }, result.Items.Select(i => i.Id).OrderBy(id => id).ToArray()); + } + + [Fact] + public async Task SearchAsync_AllWeeksWindowWithFlagFalse_ExcludesUndatedRows() + { + await using var context = CreateContext(); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 8, 4)), + Wo(2, null, status: LifecycleStatus.Incomplete)); + await context.SaveChangesAsync(); + + var result = await CreateService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = new DateOnly(2000, 1, 1), + DateTo = new DateOnly(2099, 12, 31), + IncludeDateless = false, + PageSize = 50 + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1 }, result.Items.Select(i => i.Id).ToArray()); + } + + [Theory] + [InlineData(null, null, null, null, true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "1970-01-01", "2099-12-31", true)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2000-01-02", "2099-12-31", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.Custom, "2026-09-21", "2026-09-25", false)] + [InlineData(null, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, false)] + [InlineData(false, null, null, null, false)] + [InlineData(true, WorkOrderAdvancedSearchDatePreset.ThisWeek, null, null, true)] + public void ResolveIncludeDateless_InfersOnlyForNoRangeRequests( + bool? flag, + WorkOrderAdvancedSearchDatePreset? preset, + string? from, + string? to, + bool expected) + { + Assert.Equal(expected, WorkOrderSearchDateRangeResolver.ResolveIncludeDateless( + flag, + preset, + from is null ? null : DateOnly.Parse(from, CultureInfo.InvariantCulture), + to is null ? null : DateOnly.Parse(to, CultureInfo.InvariantCulture))); + } + + [Fact] + public async Task SearchAsync_RangeForAccountUser_NeverReturnsAnotherAccountsRows() + { + await using var context = CreateContext(); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1, "A"); + await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "B"); + context.workOrders.AddRange( + Wo(1, new DateTime(2026, 9, 23), accountId: 1), + Wo(2, new DateTime(2026, 9, 23), accountId: 2), + Wo(3, null, status: LifecycleStatus.Incomplete, accountId: 2), + Wo(4, null, targetWeek: new DateOnly(2026, 9, 21), accountId: 2)); + await context.SaveChangesAsync(); + + var user = WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"); + var strict = await CreateService(context).SearchAsync(UnassignedInRange(), user, "disp-1"); + var withDateless = await CreateService(context).SearchAsync( + UnassignedInRange(includeDateless: true), user, "disp-1"); + + Assert.Equal(new[] { 1 }, strict.Items.Select(i => i.Id).ToArray()); + Assert.Equal(new[] { 1 }, withDateless.Items.Select(i => i.Id).ToArray()); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index cd47e05..6a1f2e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -319,6 +319,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = pageSize }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -380,6 +381,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 50 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -436,6 +438,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 0, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -446,6 +449,7 @@ public class WorkOrderAdvancedSearchServiceTests DateFrom = new DateOnly(2000, 1, 1), DateTo = new DateOnly(2099, 12, 31), Dispatchers = new List { "__unassigned__" }, + IncludeDateless = true, Page = 1, PageSize = 2 }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); @@ -773,7 +777,7 @@ public class WorkOrderAdvancedSearchServiceTests } [Fact] - public async Task SearchAsync_IncludesUnscheduledRows() + public async Task SearchAsync_IncludeDateless_IncludesUnscheduledRows() { await using var context = CreateContext(); var weekStart = new DateOnly(2026, 6, 22); @@ -803,7 +807,8 @@ public class WorkOrderAdvancedSearchServiceTests { DatePreset = SeaHaven.Services.Helpers.WorkOrderAdvancedSearchDatePreset.Custom, DateFrom = weekStart, - DateTo = weekStart.AddDays(4) + DateTo = weekStart.AddDays(4), + IncludeDateless = true }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); Assert.Equal(2, result.TotalCount); @@ -1193,9 +1198,10 @@ public class WorkOrderAdvancedSearchServiceTests Assert.Equal( withFlagAbsent.Items.Select(i => i.Id).ToList(), withFlagFalse.Items.Select(i => i.Id).ToList()); - Assert.Equal(2, withFlagFalse.TotalCount); + // SH-391: an explicit range narrows strictly, so the undated row 3 is out. + Assert.Equal(1, withFlagFalse.TotalCount); Assert.Contains(withFlagFalse.Items, i => i.Id == 1); - Assert.Contains(withFlagFalse.Items, i => i.Id == 3); + Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 3); Assert.DoesNotContain(withFlagFalse.Items, i => i.Id == 2); }