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..e7c3dc6 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -87,6 +87,12 @@ 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 false so the range strictly narrows (SH-391).
+ ///
+ 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/Implementation/WorkOrderAdvancedSearchService.cs b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
index fa97dc5..c428da7 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
@@ -72,7 +72,8 @@ namespace SeaHaven.Services.Implementation
pageSize,
sortBy,
query.SortDir ?? "asc",
- accountId);
+ accountId,
+ query.IncludeDateless);
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..4bacc93
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs
@@ -0,0 +1,162 @@
+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 WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => 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)));
+ 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 }, result.Items.Select(i => i.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());
+ }
+
+ [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);
}