ToolSchedule StartEffective Recalibration Implementation Plan#
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: During .bmpkg customer-data import, rewrite each active TblToolSchedule row's StartEffective to its next scheduled run-moment so the target environment's scheduler does not fire imported schedules immediately.
Architecture: A new pure static class ToolScheduleRecurrence ports the BenefitManager scheduler's exact recurrence math (Daily/Weekly/Monthly/Yearly) and exposes a single gated entry point TryComputeStartEffective(row, now). CustomerDataRowMapper gains a BuildToolScheduleParams helper that overrides the StartEffective Dapper parameter when recalibration applies. StreamingImporter.ImportToolSchedulesAsync computes "now" as Amsterdam wall-clock (matching the scheduler's IDateLogic.Now) and uses the new helper.
Tech Stack: C# / .NET 10, Dapper (DynamicParameters), System.Text.Json.Nodes, xUnit v3 + FluentAssertions.
File Structure#
- Create:
src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs— pure recurrence math + scope-gated entry point.internal static. - Create:
tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs— unit tests for the recurrence math and the scope gate. - Modify:
src/BenefitManager.CommandCenter/Services/CustomerDataRowMapper.cs— addinternal static DynamicParameters BuildToolScheduleParams(...). - Modify:
tests/BenefitManager.CommandCenter.Tests/StreamingExporterSqlTests.csis unrelated; tests for the mapper go in a newCustomerDataRowMapperToolScheduleTests.cs. - Create:
tests/BenefitManager.CommandCenter.Tests/CustomerDataRowMapperToolScheduleTests.cs— test the param override. - Modify:
src/BenefitManager.CommandCenter/Services/CustomerDataImport/StreamingImporter.cs—ImportToolSchedulesAsynconly: Amsterdam-now + useBuildToolScheduleParams.
internal types are visible to the test project (InternalsVisibleTo is configured in BenefitManager.CommandCenter.csproj).
Task 1: Port the recurrence math (ToolScheduleRecurrence)#
Files:
- Create: src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs
- Test: tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs
- [ ] Step 1: Write the failing tests for the recurrence math
Create tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs:
using BenefitManager.CommandCenter.Services;
using FluentAssertions;
namespace BenefitManager.CommandCenter.Tests;
public class ToolScheduleRecurrenceTests
{
private static readonly DateTime AfternoonJun8 = new(2026, 6, 8, 14, 0, 0);
[Fact]
public void Daily_ReturnsNextDayAtTime_WhenNowIsAfterTodaysSlot()
{
var next = ToolScheduleRecurrence.GetNextScheduledRunMoment(
ToolScheduleRecurrence.ScheduleType.Daily, day: 0, month: null,
time: new TimeSpan(3, 15, 0), workDate: AfternoonJun8);
next.Should().Be(new DateTime(2026, 6, 9, 3, 15, 0));
}
[Fact]
public void Daily_ReturnsTodaysSlot_WhenNowIsBeforeTodaysSlot()
{
var earlyJun8 = new DateTime(2026, 6, 8, 2, 0, 0);
var next = ToolScheduleRecurrence.GetNextScheduledRunMoment(
ToolScheduleRecurrence.ScheduleType.Daily, day: 0, month: null,
time: new TimeSpan(3, 15, 0), workDate: earlyJun8);
next.Should().Be(new DateTime(2026, 6, 8, 3, 15, 0));
}
[Fact]
public void Monthly_ReturnsNextDayOfMonthAtTime()
{
// Sample Key 4861: Monthly, Day 14, Time 23:55, now Jun 8 -> Jun 14.
var next = ToolScheduleRecurrence.GetNextScheduledRunMoment(
ToolScheduleRecurrence.ScheduleType.Monthly, day: 14, month: null,
time: new TimeSpan(23, 55, 0), workDate: AfternoonJun8);
next.Should().Be(new DateTime(2026, 6, 14, 23, 55, 0));
}
[Fact]
public void Weekly_ReturnsNextMatchingWeekday()
{
// day=1 -> Monday. now Wed Jun 10 -> next Monday Jun 15 at 09:00.
var wedJun10 = new DateTime(2026, 6, 10, 12, 0, 0);
var next = ToolScheduleRecurrence.GetNextScheduledRunMoment(
ToolScheduleRecurrence.ScheduleType.Weekly, day: 1, month: null,
time: new TimeSpan(9, 0, 0), workDate: wedJun10);
next.Should().Be(new DateTime(2026, 6, 15, 9, 0, 0));
}
[Fact]
public void Yearly_ReturnsNextOccurrenceOfMonthAndDay()
{
// March 15 01:00, now Jun 2026 -> next is Mar 15 2027.
var next = ToolScheduleRecurrence.GetNextScheduledRunMoment(
ToolScheduleRecurrence.ScheduleType.Yearly, day: 15, month: 3,
time: new TimeSpan(1, 0, 0), workDate: AfternoonJun8);
next.Should().Be(new DateTime(2027, 3, 15, 1, 0, 0));
}
}
- [ ] Step 2: Run tests to verify they fail
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~ToolScheduleRecurrenceTests"
Expected: FAIL — ToolScheduleRecurrence does not exist (compile error).
- [ ] Step 3: Create
ToolScheduleRecurrencewith the ported math
Create src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs:
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace BenefitManager.CommandCenter.Services;
/// <summary>
/// Computes the next scheduled run-moment for a TblToolSchedule row.
/// The recurrence math is a faithful port of
/// Tasper.CommandCenter.Shared.Data.ToolScheduleData in the BenefitManager repo,
/// so import-time recalibration agrees with the runtime scheduler.
/// </summary>
internal static class ToolScheduleRecurrence
{
internal enum ScheduleType { Daily, Weekly, Monthly, Yearly }
// ---- Port of ToolScheduleData (behaviour unchanged) ----
public static DateTime GetNextScheduledRunMoment(
ScheduleType schedule, int day, int? month, TimeSpan time, DateTime workDate)
{
return schedule switch
{
ScheduleType.Daily => GetLastDailyRunMoment(time, workDate.AddDays(1).AddTicks(-1)),
ScheduleType.Weekly => GetLastWeeklyRunMoment(day, time, workDate.AddDays(7).AddTicks(-1)),
ScheduleType.Monthly => GetLastMonthlyRunMoment(day, time, workDate.AddMonths(1).AddTicks(-1)),
ScheduleType.Yearly => GetLastYearlyRunMoment(day, month, time, workDate.AddYears(1).AddTicks(-1)),
_ => throw new ArgumentOutOfRangeException(nameof(schedule), schedule, "Unknown schedule type")
};
}
private static DateTime GetLastDailyRunMoment(TimeSpan time, DateTime workDate)
{
var startTime = workDate.Date.Add(time);
if (startTime > workDate) startTime = startTime.AddDays(-1);
return startTime;
}
private static DateTime GetLastWeeklyRunMoment(int day, TimeSpan time, DateTime workDate)
{
var currentNumber = GetDayOfWeekNumber(workDate.DayOfWeek);
var startTime = workDate.Date.AddDays(-currentNumber).AddDays(day).Add(time);
if (startTime > workDate) startTime = startTime.AddDays(-7);
return startTime;
}
private static int GetDayOfWeekNumber(DayOfWeek dayOfWeek)
{
var dayOfWeekNumber = (int)dayOfWeek;
if (dayOfWeek == DayOfWeek.Sunday) dayOfWeekNumber = 7;
return dayOfWeekNumber;
}
private static DateTime GetLastMonthlyRunMoment(int day, TimeSpan time, DateTime workDate)
{
var date = workDate.Date.AddDays(-workDate.Day).AddDays(day).Add(time);
if (date > workDate) date = date.AddMonths(-1);
return date;
}
private static DateTime GetLastYearlyRunMoment(int day, int? month, TimeSpan time, DateTime workDate)
{
var date = month.HasValue
? new DateTime(workDate.Year, month.Value, day == 0 ? 1 : day).Add(time)
: new DateTime(workDate.Year, 1, day == 0 ? 1 : day).Add(time);
if (date > workDate) date = date.AddYears(-1);
return date;
}
}
- [ ] Step 4: Run tests to verify they pass
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~ToolScheduleRecurrenceTests"
Expected: PASS (5 tests).
- [ ] Step 5: Commit
git add src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs
git commit -m "feat(commandcenter): port ToolSchedule recurrence math for import recalibration"
Task 2: Scope-gated entry point (TryComputeStartEffective)#
Files:
- Modify: src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs
- Test: tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs
- [ ] Step 1: Write the failing scope-gate tests
Append to ToolScheduleRecurrenceTests.cs (inside the class):
private static System.Text.Json.Nodes.JsonObject Row(
string schedule = "Daily", int day = 0, int? month = null,
string time = "03:15:00", bool active = true,
string startEffective = "2023-06-06T08:17:21.7730000Z",
string? endEffective = null)
{
var obj = new System.Text.Json.Nodes.JsonObject
{
["Schedule"] = schedule,
["Day"] = day,
["Time"] = time,
["Active"] = active,
["StartEffective"] = startEffective,
};
obj["Month"] = month is null ? null : System.Text.Json.Nodes.JsonValue.Create(month.Value);
obj["EndEffective"] = endEffective is null ? null : System.Text.Json.Nodes.JsonValue.Create(endEffective);
return obj;
}
[Fact]
public void TryCompute_ActiveDaily_ReturnsNextRunMoment()
{
var result = ToolScheduleRecurrence.TryComputeStartEffective(Row(), AfternoonJun8);
result.Should().Be(new DateTime(2026, 6, 9, 3, 15, 0));
}
[Fact]
public void TryCompute_Inactive_ReturnsNull()
{
ToolScheduleRecurrence.TryComputeStartEffective(Row(active: false), AfternoonJun8)
.Should().BeNull();
}
[Fact]
public void TryCompute_EndEffectiveInPast_ReturnsNull()
{
ToolScheduleRecurrence.TryComputeStartEffective(
Row(endEffective: "2026-06-01T00:00:00.0000000Z"), AfternoonJun8)
.Should().BeNull();
}
[Fact]
public void TryCompute_EndEffectiveInFuture_ReturnsNextRunMoment()
{
ToolScheduleRecurrence.TryComputeStartEffective(
Row(endEffective: "2026-12-31T00:00:00.0000000Z"), AfternoonJun8)
.Should().Be(new DateTime(2026, 6, 9, 3, 15, 0));
}
[Fact]
public void TryCompute_UnknownSchedule_ReturnsNull()
{
ToolScheduleRecurrence.TryComputeStartEffective(Row(schedule: "Hourly"), AfternoonJun8)
.Should().BeNull();
}
[Fact]
public void TryCompute_UnparseableTime_ReturnsNull()
{
ToolScheduleRecurrence.TryComputeStartEffective(Row(time: "not-a-time"), AfternoonJun8)
.Should().BeNull();
}
- [ ] Step 2: Run tests to verify they fail
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~ToolScheduleRecurrenceTests"
Expected: FAIL — TryComputeStartEffective does not exist (compile error).
- [ ] Step 3: Add
TryComputeStartEffectivetoToolScheduleRecurrence
Insert this method into ToolScheduleRecurrence (above the GetNextScheduledRunMoment method):
/// <summary>
/// Returns the recalibrated StartEffective for an imported ToolSchedule row,
/// or <c>null</c> when the row must be imported verbatim (inactive, already
/// past EndEffective, or an unrecognised Schedule/Time).
/// </summary>
/// <param name="row">The raw exported row (fields are not FK-remapped).</param>
/// <param name="now">Import-time "now" on the scheduler's clock (Amsterdam wall-clock).</param>
public static DateTime? TryComputeStartEffective(JsonObject row, DateTime now)
{
if (row["Active"]?.GetValueKind() != JsonValueKind.True)
return null;
var endEffective = ParseDateTime(row["EndEffective"]);
if (endEffective is { } end && end <= now)
return null;
if (!Enum.TryParse<ScheduleType>(row["Schedule"]?.GetValue<string>(), ignoreCase: true, out var schedule))
return null;
if (!TimeSpan.TryParse(row["Time"]?.GetValue<string>(), CultureInfo.InvariantCulture, out var time))
return null;
var day = row["Day"]?.GetValueKind() == JsonValueKind.Number ? row["Day"]!.GetValue<int>() : 0;
int? month = row["Month"]?.GetValueKind() == JsonValueKind.Number ? row["Month"]!.GetValue<int>() : null;
return GetNextScheduledRunMoment(schedule, day, month, time, now);
}
private static DateTime? ParseDateTime(JsonNode? node)
{
if (node is null || node.GetValueKind() == JsonValueKind.Null)
return null;
return DateTime.TryParse(node.GetValue<string>(), CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out var dt) ? dt : null;
}
- [ ] Step 4: Run tests to verify they pass
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~ToolScheduleRecurrenceTests"
Expected: PASS (11 tests).
- [ ] Step 5: Commit
git add src/BenefitManager.CommandCenter/Services/ToolScheduleRecurrence.cs tests/BenefitManager.CommandCenter.Tests/ToolScheduleRecurrenceTests.cs
git commit -m "feat(commandcenter): add scope-gated TryComputeStartEffective for ToolSchedule import"
Task 3: Parameter-override helper (BuildToolScheduleParams)#
Files:
- Modify: src/BenefitManager.CommandCenter/Services/CustomerDataRowMapper.cs
- Test: tests/BenefitManager.CommandCenter.Tests/CustomerDataRowMapperToolScheduleTests.cs
- [ ] Step 1: Write the failing test
Create tests/BenefitManager.CommandCenter.Tests/CustomerDataRowMapperToolScheduleTests.cs:
using System.Text.Json.Nodes;
using BenefitManager.CommandCenter.Services;
using FluentAssertions;
namespace BenefitManager.CommandCenter.Tests;
public class CustomerDataRowMapperToolScheduleTests
{
private static readonly DateTime AfternoonJun8 = new(2026, 6, 8, 14, 0, 0);
private static JsonObject DailyRow() => new()
{
["Schedule"] = "Daily",
["Day"] = 0,
["Month"] = null,
["Time"] = "03:15:00",
["Active"] = true,
["StartEffective"] = "2023-06-06T08:17:21.7730000Z",
["EndEffective"] = null,
};
private static List<KeyValuePair<string, JsonNode?>> ColumnsFrom(JsonObject row) =>
row.Select(p => new KeyValuePair<string, JsonNode?>(p.Key, p.Value)).ToList();
[Fact]
public void BuildToolScheduleParams_OverridesStartEffective_ForActiveRow()
{
var row = DailyRow();
var p = CustomerDataRowMapper.BuildToolScheduleParams(ColumnsFrom(row), row, AfternoonJun8);
p.Get<DateTime>("StartEffective").Should().Be(new DateTime(2026, 6, 9, 3, 15, 0));
}
[Fact]
public void BuildToolScheduleParams_LeavesStartEffective_ForInactiveRow()
{
var row = DailyRow();
row["Active"] = false;
var p = CustomerDataRowMapper.BuildToolScheduleParams(ColumnsFrom(row), row, AfternoonJun8);
// Verbatim path: the original 2023 StartEffective is preserved. The verbatim
// value flows through ParseStringValue, which yields a DateTimeOffset for the
// "...Z" string, so read it as DateTimeOffset (Get<DateTime> would throw).
p.Get<DateTimeOffset>("StartEffective").Year.Should().Be(2023);
}
}
- [ ] Step 2: Run test to verify it fails
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~CustomerDataRowMapperToolScheduleTests"
Expected: FAIL — BuildToolScheduleParams does not exist (compile error).
- [ ] Step 3: Add
BuildToolScheduleParamstoCustomerDataRowMapper
In src/BenefitManager.CommandCenter/Services/CustomerDataRowMapper.cs, add this method directly after the existing BuildParams method (around line 50):
internal static DynamicParameters BuildToolScheduleParams(
List<KeyValuePair<string, JsonNode?>> columns, JsonObject row, DateTime now)
{
var p = BuildParams(columns);
if (ToolScheduleRecurrence.TryComputeStartEffective(row, now) is { } next)
p.Add("StartEffective", next, dbType: System.Data.DbType.DateTime2);
return p;
}
(CustomerDataRowMapper.cs already has using System.Text.Json.Nodes; and using Dapper;, and lives in the BenefitManager.CommandCenter.Services namespace, so ToolScheduleRecurrence and JsonObject resolve without new usings.)
- [ ] Step 4: Run test to verify it passes
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj --filter "FullyQualifiedName~CustomerDataRowMapperToolScheduleTests"
Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add src/BenefitManager.CommandCenter/Services/CustomerDataRowMapper.cs tests/BenefitManager.CommandCenter.Tests/CustomerDataRowMapperToolScheduleTests.cs
git commit -m "feat(commandcenter): add BuildToolScheduleParams to override StartEffective on import"
Task 4: Wire recalibration into the importer#
Files:
- Modify: src/BenefitManager.CommandCenter/Services/CustomerDataImport/StreamingImporter.cs (ImportToolSchedulesAsync + a private Amsterdam-zone helper)
- [ ] Step 1: Add the Amsterdam time-zone helper
In StreamingImporter.cs, add these members inside the class (e.g. just below the class opening brace at line 13):
private static readonly TimeZoneInfo AmsterdamZone = ResolveAmsterdamZone();
private static TimeZoneInfo ResolveAmsterdamZone()
{
try { return TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam"); }
catch (TimeZoneNotFoundException) { return TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"); }
}
- [ ] Step 2: Recalibrate
StartEffectiveinImportToolSchedulesAsync
Replace the body of ImportToolSchedulesAsync (lines 285-306). The two changes are: compute now once, and swap BuildParams for BuildToolScheduleParams:
private static async Task<Dictionary<int, int>> ImportToolSchedulesAsync(
Package package, SqlConnection conn, SqlTransaction tx,
Dictionary<int, int> stageKeyMap, Dictionary<int, int> versionKeyMap, CancellationToken ct)
{
var map = new Dictionary<int, int>();
if (!PartExists(package, "TblToolSchedule")) return map;
// The runtime scheduler treats stored datetimes as Amsterdam wall-clock
// (IDateLogic.Now), so recalibrate against that clock, not UTC.
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, AmsterdamZone);
await using var stream = OpenPart(package, "TblToolSchedule");
await foreach (var row in NdjsonRowReader.ReadAsync(stream, ct))
{
var oldKey = row["Key"]?.GetValue<int>() ?? 0;
var remapped = CustomerDataRowMapper.RemapToolScheduleRow(row, stageKeyMap, versionKeyMap);
if (remapped is null) continue;
var columns = remapped.Where(p => !p.Key.Equals("Key", StringComparison.OrdinalIgnoreCase)).ToList();
var (colList, paramList) = BuildColumnLists(columns);
var insertSql = $"INSERT INTO [dbo].[TblToolSchedule] ({colList}) OUTPUT INSERTED.[Key] VALUES ({paramList})";
var parameters = CustomerDataRowMapper.BuildToolScheduleParams(columns, row, now);
var newKey = await conn.QuerySingleAsync<int>(Cmd(insertSql, parameters, tx, ct));
if (oldKey != 0) map[oldKey] = newKey;
}
return map;
}
- [ ] Step 3: Build the solution to verify it compiles
Run: dotnet build src/BenefitManager.CommandCenter/BenefitManager.CommandCenter.csproj
Expected: Build succeeded, 0 errors.
- [ ] Step 4: Run the full test project
Run: dotnet test tests/BenefitManager.CommandCenter.Tests/BenefitManager.CommandCenter.Tests.csproj
Expected: PASS — all tests green (the 13 new tests plus the existing suite).
- [ ] Step 5: Commit
git add src/BenefitManager.CommandCenter/Services/CustomerDataImport/StreamingImporter.cs
git commit -m "feat(commandcenter): recalibrate ToolSchedule StartEffective on import"
Self-Review#
Spec coverage:
- Recurrence port (Daily/Weekly/Monthly/Yearly, Sunday=7) → Task 1. ✓
- Scope gate (Active, EndEffective null/future, known schedule, parseable Time) → Task 2. ✓
- Param-override via DbType.DateTime2 avoiding JSON→DateTimeOffset ambiguity → Task 3. ✓
- Amsterdam wall-clock now computed once → Task 4. ✓
- Inactive/expired/unknown rows imported verbatim → Tasks 2 & 3 (null path) + Task 4 wiring. ✓
- Sample-row equivalence (Key 4861 Monthly→Jun 14; Key 4765 Daily→next-day 03:15) → Task 1 & Task 2 tests. ✓
- Out-of-scope reactivation case → documented in spec, no task (correct). ✓
Placeholder scan: No TBD/TODO/"handle edge cases"; every code step shows full code. ✓
Type consistency: ToolScheduleRecurrence.ScheduleType, GetNextScheduledRunMoment(ScheduleType, int, int?, TimeSpan, DateTime), TryComputeStartEffective(JsonObject, DateTime), and CustomerDataRowMapper.BuildToolScheduleParams(List<KeyValuePair<string, JsonNode?>>, JsonObject, DateTime) are used identically across tasks. ✓