diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
index e7c3dc6..38b4c24 100644
--- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
+++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs
@@ -90,9 +90,11 @@ namespace SeaHaven.Services.DTOs
///
/// 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).
+ /// 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 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 c428da7..39f63dc 100644
--- a/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderAdvancedSearchService.cs
@@ -73,7 +73,11 @@ namespace SeaHaven.Services.Implementation
sortBy,
query.SortDir ?? "asc",
accountId,
- query.IncludeDateless);
+ 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
index 634c12f..f6d61b2 100644
--- a/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderAdvancedSearchDateRangeTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
@@ -55,7 +56,7 @@ public class WorkOrderAdvancedSearchDateRangeTests
return wo;
}
- private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool includeDateless = false) => new()
+ private static WorkOrderAdvancedSearchQueryDto UnassignedInRange(bool? includeDateless = null) => new()
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = RangeFrom,
@@ -149,6 +150,96 @@ public class WorkOrderAdvancedSearchDateRangeTests
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()
{