Skip to content

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 — add internal static DynamicParameters BuildToolScheduleParams(...).
  • Modify: tests/BenefitManager.CommandCenter.Tests/StreamingExporterSqlTests.cs is unrelated; tests for the mapper go in a new CustomerDataRowMapperToolScheduleTests.cs.
  • Create: tests/BenefitManager.CommandCenter.Tests/CustomerDataRowMapperToolScheduleTests.cs — test the param override.
  • Modify: src/BenefitManager.CommandCenter/Services/CustomerDataImport/StreamingImporter.csImportToolSchedulesAsync only: Amsterdam-now + use BuildToolScheduleParams.

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 ToolScheduleRecurrence with 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 TryComputeStartEffective to ToolScheduleRecurrence

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 BuildToolScheduleParams to CustomerDataRowMapper

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 StartEffective in ImportToolSchedulesAsync

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. ✓