diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml
index f5f510e5..eb04e03a 100644
--- a/.github/workflows/dotnet-core-master.yml
+++ b/.github/workflows/dotnet-core-master.yml
@@ -253,13 +253,13 @@ jobs:
- name: b
filter: "FullyQualifiedName=TimePlanning.Pn.Test.PictureSnapshotServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DanLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.DataLonFileExporterTests|FullyQualifiedName=TimePlanning.Pn.Test.ContentHandoverRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.ConfigurationSeedDataTests"
- name: c
- filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests"
+ filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanningServiceMultiShiftTests|FullyQualifiedName=TimePlanning.Pn.Test.ScheduleMessageReadTests|FullyQualifiedName=TimePlanning.Pn.Test.DeviceTokenServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GpsCoordinateServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayDayTypeRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningFlexServiceRemovedRowTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanningUpdateByCurrentUserRemovedRowTests"
- name: d
filter: "FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationVersionHistoryTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetControllerTests|FullyQualifiedName=TimePlanning.Pn.Test.PayRuleSetServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTierRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PraktikantPayLineRoutingTests|FullyQualifiedName=TimePlanning.Pn.Test.MobileFlexRecomputeAndCascadeTests|FullyQualifiedName=TimePlanning.Pn.Test.SiteWorkerResolverTests"
- name: e
filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests"
- name: f
- filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests"
+ filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests"
- name: g
filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServicePhoneNumberTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningWorkingHoursExportTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAbsenceRequestGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAuthGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DagsoversigtWorksheetExportTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursImportRemovedRowTests"
- name: h
diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml
index 4ee1d5c9..37a5c069 100644
--- a/.github/workflows/dotnet-core-pr.yml
+++ b/.github/workflows/dotnet-core-pr.yml
@@ -248,7 +248,7 @@ jobs:
- name: e
filter: "FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationIntegrationTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.PayTimeBandRuleServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperComputationTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperHolidayTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursExcelExportTagsColumnTests"
- name: f
- filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests"
+ filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServiceExtendedTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperDisplayParityTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteModeTimelineTests|FullyQualifiedName=TimePlanning.Pn.Test.OneMinuteIntervalsEffectiveDateTests|FullyQualifiedName=TimePlanning.Pn.Test.RunningFlexChainModeBoundaryTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperReadBySiteAndDateTests|FullyQualifiedName=TimePlanning.Pn.Test.PlanRegistrationHelperTests|FullyQualifiedName=TimePlanning.Pn.Test.PushNotificationServiceTests"
- name: g
filter: "FullyQualifiedName=TimePlanning.Pn.Test.SettingsServicePhoneNumberTests|FullyQualifiedName=TimePlanning.Pn.Test.TimePlanningWorkingHoursExportTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAbsenceRequestGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.GrpcServices.TimePlanningAuthGrpcServiceTests|FullyQualifiedName=TimePlanning.Pn.Test.DagsoversigtWorksheetExportTests|FullyQualifiedName=TimePlanning.Pn.Test.WorkingHoursImportRemovedRowTests"
- name: h
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs
new file mode 100644
index 00000000..9dc32705
--- /dev/null
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/OneMinuteIntervalsEffectiveDateTests.cs
@@ -0,0 +1,296 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microting.TimePlanningBase.Infrastructure.Data.Entities;
+using NUnit.Framework;
+using TimePlanning.Pn.Infrastructure.Helpers;
+
+namespace TimePlanning.Pn.Test;
+
+///
+/// Pure in-memory unit tests (no DbContext) for the one-minute-intervals
+/// EFFECTIVE-DATE fix.
+///
+/// Background: AssignedSite.UseOneMinuteIntervals is a per-site boolean
+/// with no effective-from date, so every flex recomputation re-derived a
+/// worker's ENTIRE history under the site's CURRENT mode — switching it on
+/// silently restated already-closed periods at one-minute precision.
+/// AssignedSite.UseOneMinuteIntervalsFrom records when the flag took
+/// effect; NULL means "nothing recorded" and preserves today's behaviour.
+///
+/// Covered here:
+/// - — the single
+/// place the stored date becomes a verdict (before / on / after, date-only,
+/// NULL falls through, flag-off short-circuit).
+/// - — the stored date wins
+/// over the AssignedSiteVersions-derived timeline; a NULL date falls through
+/// to that timeline unchanged.
+/// - — the per-row
+/// write-time marker outranks both.
+/// - — the
+/// reverse seed fallback (SumFlexEndInSeconds is 0 on ~97% of rows).
+/// - — the
+/// false→true settings stamp and its no-clobber guard.
+///
+[TestFixture]
+public class OneMinuteIntervalsEffectiveDateTests
+{
+ private static readonly DateTime EffectiveFrom = new(2026, 6, 1, 14, 45, 0); // mid-day save
+ private static readonly DateTime StampedAt = new(2026, 8, 31, 10, 0, 0, DateTimeKind.Utc);
+
+ // ---------------------------------------------------------------- //
+ // 1. ResolveByEffectiveDate — the single resolution expression //
+ // ---------------------------------------------------------------- //
+
+ [Test]
+ public void EffectiveDate_Null_ReturnsNull_SoCallersFallThroughToTheTimeline()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(true, null, new DateTime(2026, 6, 1)),
+ Is.Null, "Nothing recorded → the derived timeline must answer.");
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(false, null, new DateTime(2026, 6, 1)),
+ Is.Null);
+ });
+ }
+
+ [Test]
+ public void EffectiveDate_Set_SplitsOnTheDate_DateOnly()
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 5, 31)),
+ Is.False, "The day BEFORE the effective date stays 5-minute.");
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 6, 1)),
+ Is.True,
+ "A PlanRegistration.Date is a midnight anchor: an effective date saved at "
+ + "14:45 still governs the WHOLE of that day (date-only comparison).");
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2026, 6, 2)),
+ Is.True);
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(true, EffectiveFrom, new DateTime(2030, 1, 1)),
+ Is.True);
+ });
+ }
+
+ [Test]
+ public void EffectiveDate_Set_ButFlagOff_IsFalseEverywhere()
+ {
+ // The flag is one-way in the settings path, but an ops/raw-SQL turn-off
+ // must not resurrect one-minute mode from a stale recorded date.
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(false, EffectiveFrom, new DateTime(2026, 5, 1)),
+ Is.False);
+ Assert.That(
+ OneMinuteModeTimeline.ResolveByEffectiveDate(false, EffectiveFrom, new DateTime(2027, 1, 1)),
+ Is.False);
+ });
+ }
+
+ // ---------------------------------------------------------------- //
+ // 2. Timeline precedence — stored date beats the derived trail //
+ // ---------------------------------------------------------------- //
+
+ [Test]
+ public void StoredEffectiveDate_OverridesTheDerivedVersionTimeline()
+ {
+ // The audit trail says the flag flipped on 2026-03-01, but ops recovered
+ // the real transition and recorded 2026-06-01. The stored date wins.
+ var timeline = new OneMinuteModeTimeline(
+ true,
+ new List<(bool, DateTime)>
+ {
+ (false, new DateTime(2026, 1, 1)),
+ (true, new DateTime(2026, 3, 1))
+ },
+ EffectiveFrom);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 3, 15)), Is.False,
+ "The derived trail would say true here; the recorded date says otherwise.");
+ Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 5, 31)), Is.False);
+ Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 6, 1)), Is.True);
+ });
+ }
+
+ [Test]
+ public void NullEffectiveDate_LeavesTheDerivedTimelineUntouched()
+ {
+ var timeline = new OneMinuteModeTimeline(
+ true,
+ new List<(bool, DateTime)>
+ {
+ (false, new DateTime(2026, 1, 1)),
+ (true, new DateTime(2026, 3, 1))
+ });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 2, 1)), Is.False);
+ Assert.That(timeline.WasOneMinuteAt(new DateTime(2026, 3, 1)), Is.True,
+ "With nothing recorded the AssignedSiteVersions walk still governs.");
+ });
+ }
+
+ // ---------------------------------------------------------------- //
+ // 3. Per-row precedence — the write-time marker outranks both //
+ // ---------------------------------------------------------------- //
+
+ // The dbContext argument is only touched when neither the marker nor the
+ // recorded effective date can answer, so these cases can pass null for it.
+
+ [Test]
+ public async Task RowMarker_WinsOverTheEffectiveDate()
+ {
+ var site = new AssignedSite
+ {
+ UseOneMinuteIntervals = true,
+ UseOneMinuteIntervalsFrom = EffectiveFrom
+ };
+
+ // Registered under one-minute mode on a date BEFORE the effective date
+ // (e.g. an admin re-registered the day after the flip): the marker is
+ // ground truth and must win.
+ var markedOneMinute = new PlanRegistration
+ {
+ Date = new DateTime(2026, 1, 15),
+ RegisteredUnderOneMinuteIntervals = true
+ };
+ // Registered under 5-minute mode on a date AFTER the effective date.
+ var markedFiveMinute = new PlanRegistration
+ {
+ Date = new DateTime(2026, 9, 15),
+ RegisteredUnderOneMinuteIntervals = false
+ };
+
+ Assert.That(
+ await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, markedOneMinute),
+ Is.True);
+ Assert.That(
+ await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, markedFiveMinute),
+ Is.False);
+ }
+
+ [Test]
+ public async Task UnmarkedRow_ResolvesFromTheEffectiveDate()
+ {
+ var site = new AssignedSite
+ {
+ UseOneMinuteIntervals = true,
+ UseOneMinuteIntervalsFrom = EffectiveFrom
+ };
+
+ var before = new PlanRegistration { Date = new DateTime(2026, 5, 31) };
+ var onTheDay = new PlanRegistration { Date = new DateTime(2026, 6, 1) };
+ var after = new PlanRegistration { Date = new DateTime(2026, 7, 1) };
+
+ Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, before), Is.False,
+ "A closed pre-switch day must NOT be recomputed at one-minute precision.");
+ Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, onTheDay), Is.True);
+ Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, site, after), Is.True);
+ }
+
+ [Test]
+ public async Task NoAssignedSite_ResolvesToFiveMinute()
+ {
+ var row = new PlanRegistration { Date = new DateTime(2026, 6, 1) };
+ Assert.That(await OneMinuteModeTimeline.ResolveRowModeAsync(null!, null, row), Is.False);
+ }
+
+ // ---------------------------------------------------------------- //
+ // 4. Reverse seed fallback //
+ // ---------------------------------------------------------------- //
+
+ [Test]
+ public void SeedFallback_NullPredecessor_IsZero()
+ {
+ Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(null), Is.EqualTo(0));
+ }
+
+ [Test]
+ public void SeedFallback_PopulatedSecondsWin()
+ {
+ var pre = new PlanRegistration { SumFlexEndInSeconds = 7261, SumFlexEnd = 99 };
+ Assert.That(PlanRegistrationHelper.SumFlexEndSecondsWithFallback(pre), Is.EqualTo(7261),
+ "When the seconds column is populated it is the source of truth.");
+ }
+
+ [Test]
+ public void SeedFallback_ZeroSeconds_FallsBackToTheDecimalBalance()
+ {
+ // Migration 20260108054344 added SumFlexEndInSeconds with defaultValue 0
+ // and no backfill, so on ~97% of rows the real balance is only in the
+ // decimal. Seeding from the raw column discards the whole balance —
+ // which also fires on the FIRST post-switch row, whose predecessor is by
+ // definition a pre-switch row that only ever had decimals written.
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(
+ new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 12.5 }),
+ Is.EqualTo(45000));
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(
+ new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = -2.25 }),
+ Is.EqualTo(-8100), "A negative carried balance survives the fallback.");
+ Assert.That(
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(
+ new PlanRegistration { SumFlexEndInSeconds = 0, SumFlexEnd = 0 }),
+ Is.EqualTo(0), "A genuine zero and an unbackfilled zero agree.");
+ });
+ }
+
+ // ---------------------------------------------------------------- //
+ // 5. The settings stamp //
+ // ---------------------------------------------------------------- //
+
+ [Test]
+ public void Stamp_FiresOnFalseToTrue()
+ {
+ var site = new AssignedSite { UseOneMinuteIntervals = false, UseOneMinuteIntervalsFrom = null };
+ OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt);
+ Assert.That(site.UseOneMinuteIntervalsFrom, Is.EqualTo(StampedAt));
+ }
+
+ [Test]
+ public void Stamp_DoesNotFireWhenAlreadyTrue()
+ {
+ // UseOneMinuteIntervals is one-way (it is ORed with the incoming value),
+ // so every later settings save re-submits true. Stamping again here
+ // would move the effective date forward on every save.
+ var site = new AssignedSite { UseOneMinuteIntervals = true, UseOneMinuteIntervalsFrom = null };
+ OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt);
+ Assert.That(site.UseOneMinuteIntervalsFrom, Is.Null);
+ }
+
+ [Test]
+ public void Stamp_DoesNotOverwriteAnExistingDate()
+ {
+ // An ops script backfills recovered historical dates; a later settings
+ // save must not clobber one with today's date.
+ var backfilled = new DateTime(2025, 4, 2, 8, 0, 0, DateTimeKind.Utc);
+ var site = new AssignedSite
+ {
+ UseOneMinuteIntervals = false,
+ UseOneMinuteIntervalsFrom = backfilled
+ };
+ OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, true, StampedAt);
+ Assert.That(site.UseOneMinuteIntervalsFrom, Is.EqualTo(backfilled));
+ }
+
+ [Test]
+ public void Stamp_DoesNotFireWhenIncomingIsFalse()
+ {
+ var site = new AssignedSite { UseOneMinuteIntervals = false, UseOneMinuteIntervalsFrom = null };
+ OneMinuteModeTimeline.StampEffectiveDateOnEnable(site, false, StampedAt);
+ Assert.That(site.UseOneMinuteIntervalsFrom, Is.Null);
+ }
+}
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs
new file mode 100644
index 00000000..fbe48d99
--- /dev/null
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/RunningFlexChainModeBoundaryTests.cs
@@ -0,0 +1,318 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NUnit.Framework;
+using TimePlanning.Pn.Infrastructure.Helpers;
+using TimePlanning.Pn.Infrastructure.Models.WorkingHours.Index;
+using TimePlanning.Pn.Services.TimePlanningWorkingHoursService;
+
+namespace TimePlanning.Pn.Test;
+
+///
+/// Pure in-memory unit tests (no DbContext) for
+/// TimePlanningWorkingHoursService.ApplyRunningFlexChain across a
+/// UseOneMinuteIntervals MODE BOUNDARY inside a single list — the production
+/// scenario of a worker whose displayed period spans the site's flip.
+///
+/// The chain forks per row: a one-minute row runs in the integer
+/// *InSeconds columns and back-derives the doubles; a five-minute row
+/// runs in the legacy 2-decimal doubles and its *InSeconds DTO fields are
+/// deliberately NOT written (the flag-off response stays byte-identical, and ops
+/// reads a zero there as the signal that the row never ran in one-minute mode).
+/// Both running accumulators are nonetheless advanced on every row so the
+/// balance carries across the boundary — that hand-off is what these tests pin.
+///
+/// The chain only reads logger from inside its catch blocks, so the
+/// service is constructed with a substitute logger and nulls for every other
+/// dependency; nothing here touches a database.
+///
+[TestFixture]
+public class RunningFlexChainModeBoundaryTests
+{
+ private TimePlanningWorkingHoursService _service = null!;
+
+ /// A timeline that is never consulted (every row carries a marker).
+ private static OneMinuteModeTimeline UnusedTimeline
+ => new(false, Array.Empty<(bool, DateTime)>());
+
+ [SetUp]
+ public void SetUp()
+ {
+ _service = new TimePlanningWorkingHoursService(
+ Substitute.For>(),
+ dbContext: null!,
+ userService: null!,
+ localizationService: null!,
+ baseDbContext: null!,
+ options: null!,
+ coreHelper: null!);
+ }
+
+ private static TimePlanningWorkingHoursModel FiveMinuteRow(
+ int dayOfMonth, double flexHours, string paidOutFlex = "0",
+ double sumFlexStart = 0, int sumFlexEndInSeconds = 0)
+ => new()
+ {
+ Date = new DateTime(2026, 6, dayOfMonth),
+ RegisteredUnderOneMinuteIntervals = false,
+ FlexHours = flexHours,
+ PaidOutFlex = paidOutFlex,
+ SumFlexStart = sumFlexStart,
+ SumFlexEndInSeconds = sumFlexEndInSeconds
+ };
+
+ private static TimePlanningWorkingHoursModel OneMinuteRow(
+ int dayOfMonth, int flexInSeconds, int paiedOutFlexInSeconds = 0,
+ double sumFlexStart = 0, int sumFlexStartInSeconds = 0)
+ => new()
+ {
+ Date = new DateTime(2026, 6, dayOfMonth),
+ RegisteredUnderOneMinuteIntervals = true,
+ FlexInSeconds = flexInSeconds,
+ PaidOutFlex = "0",
+ PaiedOutFlexInSeconds = paiedOutFlexInSeconds,
+ SumFlexStart = sumFlexStart,
+ SumFlexStartInSeconds = sumFlexStartInSeconds
+ };
+
+ // ------------------------------------------------------------------ //
+ // 1. Five-minute rows followed by one-minute rows (the real flip) //
+ // ------------------------------------------------------------------ //
+
+ [Test]
+ public void FiveMinuteThenOneMinute_CarriesTheBalanceToTheSecond()
+ {
+ // 2.00 h opening, +1.50 h, -0.50 h → 3.00 h at the boundary,
+ // then +30 m 37 s and -37 s in one-minute mode.
+ var rows = new List
+ {
+ FiveMinuteRow(1, flexHours: 1.5, sumFlexStart: 2.0),
+ FiveMinuteRow(2, flexHours: -0.5),
+ OneMinuteRow(3, flexInSeconds: 1837),
+ OneMinuteRow(4, flexInSeconds: -37)
+ };
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3.5).Within(1e-9));
+ Assert.That(rows[1].SumFlexEnd, Is.EqualTo(3.0).Within(1e-9));
+
+ // The hand-off: the first post-flip row opens on exactly the
+ // pre-flip closing balance, in seconds.
+ Assert.That(rows[2].SumFlexStartInSeconds, Is.EqualTo(10800),
+ "3.00 h carried across the boundary as 10800 s.");
+ Assert.That(rows[2].SumFlexEndInSeconds, Is.EqualTo(12637),
+ "10800 + 1837 — the 37 s survives the boundary.");
+ Assert.That(rows[3].SumFlexStartInSeconds, Is.EqualTo(12637));
+ Assert.That(rows[3].SumFlexEndInSeconds, Is.EqualTo(12600));
+ Assert.That(rows[3].SumFlexEnd, Is.EqualTo(3.5).Within(1e-9));
+
+ // Lockstep on every one-minute row: the double is the exact
+ // back-derivation of the integer source of truth.
+ Assert.That(rows[2].SumFlexEnd, Is.EqualTo(rows[2].SumFlexEndInSeconds / 3600.0));
+ Assert.That(rows[3].SumFlexEnd, Is.EqualTo(rows[3].SumFlexEndInSeconds / 3600.0));
+
+ // Five-minute rows keep their *InSeconds DTO fields untouched — by
+ // design, not by omission (see the fixture summary).
+ Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(0));
+ Assert.That(rows[1].SumFlexEndInSeconds, Is.EqualTo(0));
+ });
+ }
+
+ // ------------------------------------------------------------------ //
+ // 2. One-minute rows followed by five-minute rows (the reverse) //
+ // ------------------------------------------------------------------ //
+
+ ///
+ /// The seconds → double hand-off is LOSSLESS: the five-minute row opens on
+ /// the full-precision SumFlexEndInSeconds / 3600.0, not on a
+ /// whole-minute or 2-decimal truncation of it.
+ ///
+ /// The five-minute row's own OUTPUT is still Math.Round(…, 2) — that
+ /// is the pre-existing legacy formula for every five-minute row and is not
+ /// changed here — so a subsequent return to one-minute mode rebuilds the
+ /// seconds accumulator from that rounded double and can lose up to 18 s.
+ /// That is pinned below rather than left silent. It does not arise in
+ /// production: UseOneMinuteIntervals is one-way, so a chain never
+ /// crosses back from one-minute to five-minute.
+ ///
+ [Test]
+ public void OneMinuteThenFiveMinute_HandsOffFullPrecision()
+ {
+ // 1 h 0 m 37 s — deliberately not a whole number of minutes, so any
+ // truncation in the hand-off would show.
+ var rows = new List
+ {
+ OneMinuteRow(1, flexInSeconds: 3637),
+ FiveMinuteRow(2, flexHours: 0.5),
+ OneMinuteRow(3, flexInSeconds: 0)
+ };
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(3637));
+ Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3637 / 3600.0));
+
+ Assert.That(rows[1].SumFlexStart, Is.EqualTo(3637 / 3600.0),
+ "The five-minute row opens on the EXACT seconds balance — the "
+ + "hand-off applies no rounding of its own.");
+ Assert.That(rows[1].SumFlexEnd, Is.EqualTo(1.51).Within(1e-9),
+ "Its own output is 2-decimal rounded by the pre-existing legacy "
+ + "formula (1.5102777… h → 1.51).");
+
+ Assert.That(rows[2].SumFlexStartInSeconds, Is.EqualTo(5436),
+ "Returning to one-minute mode rebuilds seconds from the rounded "
+ + "double: 5437 s becomes 5436 s. Inherent to the legacy "
+ + "five-minute rounding; unreachable in production because the "
+ + "flag is one-way.");
+ });
+ }
+
+ // ------------------------------------------------------------------ //
+ // 3. Regression guard: a uniformly five-minute list is unchanged //
+ // ------------------------------------------------------------------ //
+
+ ///
+ /// The common case. Expected values are the pre-change legacy formulas
+ /// computed by hand:
+ /// row 0: SumFlexStart = Round(1.234567, 2) = 1.23
+ /// SumFlexEnd = Round(1.23 + 2.5 - 0.25, 2) = 3.48
+ /// row 1: SumFlexEnd = Round(3.48 - 1.1 - 0, 2) = 2.38
+ /// row 2: SumFlexEnd = Round(2.38 + 0.333333 - 0.5, 2) = 2.21
+ /// The sentinel *InSeconds values prove the seconds bookkeeping added
+ /// for the boundary hand-off writes nothing on a five-minute row.
+ ///
+ [Test]
+ public void UniformlyFiveMinute_MatchesTheLegacyFormulasAndWritesNoSeconds()
+ {
+ const int sentinel = 424242;
+ var rows = new List
+ {
+ FiveMinuteRow(1, flexHours: 2.5, paidOutFlex: "0.25",
+ sumFlexStart: 1.234567, sumFlexEndInSeconds: sentinel),
+ FiveMinuteRow(2, flexHours: -1.1, sumFlexEndInSeconds: sentinel),
+ FiveMinuteRow(3, flexHours: 0.333333, paidOutFlex: "0.5",
+ sumFlexEndInSeconds: sentinel)
+ };
+ foreach (var row in rows)
+ {
+ row.SumFlexStartInSeconds = sentinel;
+ }
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexStart, Is.EqualTo(1.23).Within(1e-9));
+ Assert.That(rows[0].SumFlexEnd, Is.EqualTo(3.48).Within(1e-9));
+ Assert.That(rows[1].SumFlexStart, Is.EqualTo(3.48).Within(1e-9));
+ Assert.That(rows[1].SumFlexEnd, Is.EqualTo(2.38).Within(1e-9));
+ Assert.That(rows[2].SumFlexStart, Is.EqualTo(2.38).Within(1e-9));
+ Assert.That(rows[2].SumFlexEnd, Is.EqualTo(2.21).Within(1e-9));
+
+ foreach (var row in rows)
+ {
+ Assert.That(row.SumFlexStartInSeconds, Is.EqualTo(sentinel));
+ Assert.That(row.SumFlexEndInSeconds, Is.EqualTo(sentinel));
+ }
+ });
+ }
+
+ // ------------------------------------------------------------------ //
+ // 4. The anchor seed — the balance-collapse mechanism itself //
+ // ------------------------------------------------------------------ //
+
+ [Test]
+ public void OneMinuteAnchor_WithZeroSecondsColumn_StartsFromTheDecimalBalance()
+ {
+ // SumFlexEndInSeconds / SumFlexStartInSeconds are 0 on ~97% of rows
+ // (migration 20260108054344, defaultValue 0, no backfill). Seeding the
+ // chain from the raw column discarded the whole 4.25 h opening balance.
+ var rows = new List
+ {
+ OneMinuteRow(1, flexInSeconds: 900, sumFlexStart: 4.25, sumFlexStartInSeconds: 0)
+ };
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexStartInSeconds, Is.EqualTo(15300),
+ "4.25 h — NOT 0.");
+ Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(16200));
+ Assert.That(rows[0].SumFlexEnd, Is.EqualTo(4.5).Within(1e-9));
+ });
+ }
+
+ [Test]
+ public void OneMinuteAnchor_WithPopulatedSecondsColumn_IgnoresTheDecimal()
+ {
+ var rows = new List
+ {
+ OneMinuteRow(1, flexInSeconds: 0, sumFlexStart: 99, sumFlexStartInSeconds: 7200)
+ };
+
+ _service.ApplyRunningFlexChain(rows, UnusedTimeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rows[0].SumFlexStartInSeconds, Is.EqualTo(7200),
+ "A populated seconds column is the source of truth.");
+ Assert.That(rows[0].SumFlexEndInSeconds, Is.EqualTo(7200));
+ });
+ }
+
+ // ------------------------------------------------------------------ //
+ // 5. End to end: unmarked rows split by the site's effective date //
+ // ------------------------------------------------------------------ //
+
+ ///
+ /// The full production shape: legacy rows carrying NO write-time marker,
+ /// with the boundary supplied by the site's recorded
+ /// UseOneMinuteIntervalsFrom (2026-06-01). Days before it must stay
+ /// on 5-minute rules; the flip day onwards runs in seconds.
+ ///
+ [Test]
+ public void UnmarkedRows_SplitByTheSitesEffectiveDate()
+ {
+ var timeline = new OneMinuteModeTimeline(
+ currentFlag: true,
+ versionFlags: Array.Empty<(bool, DateTime)>(),
+ effectiveFrom: new DateTime(2026, 6, 1, 14, 45, 0));
+
+ var before = new TimePlanningWorkingHoursModel
+ {
+ Date = new DateTime(2026, 5, 30),
+ RegisteredUnderOneMinuteIntervals = null,
+ FlexHours = 1.0,
+ PaidOutFlex = "0",
+ SumFlexStart = 1.0
+ };
+ var onTheFlipDay = new TimePlanningWorkingHoursModel
+ {
+ Date = new DateTime(2026, 6, 1),
+ RegisteredUnderOneMinuteIntervals = null,
+ FlexInSeconds = 61,
+ PaidOutFlex = "0"
+ };
+ var rows = new List { before, onTheFlipDay };
+
+ _service.ApplyRunningFlexChain(rows, timeline);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(before.SumFlexEnd, Is.EqualTo(2.0).Within(1e-9),
+ "Pre-flip day recomputed under 5-minute rules.");
+ Assert.That(before.SumFlexEndInSeconds, Is.EqualTo(0),
+ "…and its seconds DTO field left untouched.");
+ Assert.That(onTheFlipDay.SumFlexStartInSeconds, Is.EqualTo(7200),
+ "The flip day opens on the pre-flip balance.");
+ Assert.That(onTheFlipDay.SumFlexEndInSeconds, Is.EqualTo(7261));
+ });
+ }
+}
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs
index c35cdfad..e04850cd 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/OneMinuteModeTimeline.cs
@@ -5,6 +5,7 @@
using Microsoft.EntityFrameworkCore;
using Microting.TimePlanningBase.Infrastructure.Data;
using AssignedSite = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite;
+using PlanRegistration = Microting.TimePlanningBase.Infrastructure.Data.Entities.PlanRegistration;
namespace TimePlanning.Pn.Infrastructure.Helpers;
@@ -54,6 +55,27 @@ namespace TimePlanning.Pn.Infrastructure.Helpers;
/// earliest possible un-audited flip point. Audited history before that
/// date is preserved; for sites flipped through the API this is a no-op.
///
+/// Authoritative override: AssignedSite.UseOneMinuteIntervalsFrom.
+/// The derived timeline above is a RECONSTRUCTION; when ops (or the
+/// settings save in TimeSettingService.UpdateAssignedSite) has
+/// recorded the date the flag actually took effect, that stored date is the
+/// truth and the reconstruction is not consulted at all:
+/// UseOneMinuteIntervals && rowDate >= UseOneMinuteIntervalsFrom
+/// (date-only, same granularity rule as above). A NULL column means "nothing
+/// recorded" and falls through to the derived timeline, which keeps today's
+/// behaviour for every site ops has not backfilled.
+///
+/// Full per-row precedence (see ):
+/// 1. PlanRegistration.RegisteredUnderOneMinuteIntervals — the
+/// write-time marker, ground truth for rows that carry one.
+/// 2. UseOneMinuteIntervalsFrom — the stored effective date.
+/// 3. the AssignedSiteVersions-derived timeline.
+///
+/// The class also OWNS THE WRITE SIDE of that column:
+/// is what records the date when the
+/// settings save flips the flag on, so the read rule and the write rule cannot
+/// drift apart.
+///
/// Cost: ONE query per site (); lookups are pure
/// in-memory. Build once per site per request scope — never per row.
///
@@ -61,6 +83,15 @@ public sealed class OneMinuteModeTimeline
{
private readonly bool _initialValue;
+ /// The site's CURRENT flag (also the effective-date verdict's value).
+ private readonly bool _currentFlag;
+
+ ///
+ /// The authoritative date the current flag took effect, when recorded;
+ /// NULL means "not recorded" and the derived timeline is used instead.
+ ///
+ private readonly DateTime? _effectiveFrom;
+
/// Date-only change points in save order (date, value-from-that-date).
private readonly List<(DateTime Date, bool Value)> _changePoints;
@@ -72,11 +103,17 @@ public sealed class OneMinuteModeTimeline
/// no-version-rows fallback AND the divergence-correction authority (see
/// class docs): when the trail does not end on this value, the current
/// flag takes over from the last audited save date.
+ /// is the site's recorded
+ /// UseOneMinuteIntervalsFrom; when non-null it OVERRIDES the derived
+ /// timeline entirely (see ).
///
internal OneMinuteModeTimeline(
bool currentFlag,
- IReadOnlyList<(bool UseOneMinuteIntervals, DateTime SavedAt)> versionFlags)
+ IReadOnlyList<(bool UseOneMinuteIntervals, DateTime SavedAt)> versionFlags,
+ DateTime? effectiveFrom = null)
{
+ _currentFlag = currentFlag;
+ _effectiveFrom = effectiveFrom;
_changePoints = new List<(DateTime, bool)>();
if (versionFlags == null || versionFlags.Count == 0)
@@ -113,11 +150,19 @@ internal OneMinuteModeTimeline(
///
/// Builds the timeline for one AssignedSite with a single
/// AssignedSiteVersions query. An unsaved entity (Id == 0) or a site
- /// without audit rows yields a constant timeline of the current flag.
+ /// without audit rows yields a constant timeline of the current flag;
+ /// a null site (no AssignedSite row for the worker) yields a constant
+ /// 5-minute timeline, so callers never need their own empty-timeline
+ /// fallback.
///
public static async Task BuildAsync(
- TimePlanningPnDbContext dbContext, AssignedSite assignedSite)
+ TimePlanningPnDbContext dbContext, AssignedSite? assignedSite)
{
+ if (assignedSite == null)
+ {
+ return new OneMinuteModeTimeline(false, Array.Empty<(bool, DateTime)>());
+ }
+
var versionFlags = await dbContext.AssignedSiteVersions
.AsNoTracking()
.Where(x => x.AssignedSiteId == assignedSite.Id)
@@ -132,15 +177,99 @@ public static async Task BuildAsync(
assignedSite.UseOneMinuteIntervals,
versionFlags
.Select(x => (x.UseOneMinuteIntervals, x.UpdatedAt ?? x.CreatedAt))
- .ToList());
+ .ToList(),
+ assignedSite.UseOneMinuteIntervalsFrom);
+ }
+
+ ///
+ /// The ONE place the stored effective date is turned into a verdict.
+ /// Returns null when nothing is recorded (
+ /// is NULL) so the caller falls through to the derived timeline; otherwise
+ /// the flag applies only from that date onwards. DATE-ONLY comparison — a
+ /// PlanRegistration.Date is a midnight anchor with no time-of-day,
+ /// matching the timeline's own granularity rule.
+ ///
+ internal static bool? ResolveByEffectiveDate(
+ bool currentFlag, DateTime? effectiveFrom, DateTime rowDate)
+ => effectiveFrom == null
+ ? null
+ : currentFlag && rowDate.Date >= effectiveFrom.Value.Date;
+
+ ///
+ /// Records WHEN one-minute intervals took effect, on the false→true
+ /// transition only. Must be called BEFORE the caller ORs the incoming value
+ /// into the stored flag: UseOneMinuteIntervals is deliberately
+ /// one-way (commit 994c9cd4), so after the OR a real transition is
+ /// indistinguishable from "was already true".
+ ///
+ /// The UseOneMinuteIntervalsFrom == null guard is required: an ops
+ /// backfill of recovered historical dates must not be clobbered with
+ /// today's date by an unrelated later settings save. The column is ops-only
+ /// — written by script or by this stamp, never exposed on a DTO.
+ ///
+ public static void StampEffectiveDateOnEnable(
+ AssignedSite dbAssignedSite, bool incomingUseOneMinuteIntervals, DateTime now)
+ {
+ if (!dbAssignedSite.UseOneMinuteIntervals
+ && incomingUseOneMinuteIntervals
+ && dbAssignedSite.UseOneMinuteIntervalsFrom == null)
+ {
+ dbAssignedSite.UseOneMinuteIntervalsFrom = now;
+ }
+ }
+
+ ///
+ /// Resolves the mode for ONE row under the full precedence (write-time
+ /// marker → stored effective date → derived timeline), querying
+ /// AssignedSiteVersions only when neither of the first two can answer.
+ /// Use this from calc paths that hold a single row; loops that already
+ /// build a timeline should keep using
+ /// row.RegisteredUnderOneMinuteIntervals ?? timeline.WasOneMinuteAt(row.Date),
+ /// which carries the same precedence because
+ /// consults the effective date first.
+ ///
+ /// NEVER call this in a loop: on a legacy row of an un-backfilled site it
+ /// falls through to , so a per-row call is the
+ /// exact N+1 this class exists to avoid. Build a timeline once instead.
+ ///
+ public static async Task ResolveRowModeAsync(
+ TimePlanningPnDbContext dbContext, AssignedSite? assignedSite, PlanRegistration row)
+ {
+ if (row.RegisteredUnderOneMinuteIntervals.HasValue)
+ {
+ return row.RegisteredUnderOneMinuteIntervals.Value;
+ }
+
+ if (assignedSite == null)
+ {
+ return false;
+ }
+
+ var byEffectiveDate = ResolveByEffectiveDate(
+ assignedSite.UseOneMinuteIntervals, assignedSite.UseOneMinuteIntervalsFrom, row.Date);
+ if (byEffectiveDate.HasValue)
+ {
+ return byEffectiveDate.Value;
+ }
+
+ var timeline = await BuildAsync(dbContext, assignedSite);
+ return timeline.WasOneMinuteAt(row.Date);
}
///
/// The UseOneMinuteIntervals value in force on
- /// (date-only comparison; the time component is ignored).
+ /// (date-only comparison; the time component is ignored). The site's
+ /// recorded UseOneMinuteIntervalsFrom wins when present; only when
+ /// nothing is recorded does the AssignedSiteVersions-derived walk answer.
///
public bool WasOneMinuteAt(DateTime rowDate)
{
+ var byEffectiveDate = ResolveByEffectiveDate(_currentFlag, _effectiveFrom, rowDate);
+ if (byEffectiveDate.HasValue)
+ {
+ return byEffectiveDate.Value;
+ }
+
var date = rowDate.Date;
var value = _initialValue;
// Walk ALL change points in save order (no early break): the LAST save
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
index 0421f292..377c5b48 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
@@ -457,6 +457,38 @@ public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIn
return (int)(totalSeconds / 60); // round down to whole minutes
}
+ ///
+ /// Reads an *InSeconds column, falling back to its legacy
+ /// double hour sibling when the column is still 0.
+ ///
+ /// Every *InSeconds column was added by a migration with
+ /// defaultValue: 0 and NO backfill (SumFlexEndInSeconds by
+ /// 20260108054344), so on the overwhelming majority of historical rows the
+ /// column reads 0 while the real value lives in the decimal. Taking the
+ /// column at face value silently substitutes zero for a real balance.
+ ///
+ /// A genuine zero and an unbackfilled zero are indistinguishable, which is
+ /// harmless: both fall back to the decimal, and a genuinely-zero row has a
+ /// zero decimal too.
+ ///
+ public static int SecondsOrDecimalFallback(int seconds, double hours)
+ => seconds != 0 ? seconds : (int)Math.Round(hours * 3600);
+
+ ///
+ /// Seeds the running flex chain from the preceding day's closing balance,
+ /// in seconds, via ; 0 when there is
+ /// no preceding row.
+ ///
+ /// The fallback is load-bearing at a one-minute mode transition: the first
+ /// post-switch row seeds from the last PRE-switch row, which by definition
+ /// only ever had the decimal columns written.
+ ///
+ public static int SumFlexEndSecondsWithFallback(PlanRegistration? preTimePlanning)
+ => preTimePlanning == null
+ ? 0
+ : SecondsOrDecimalFallback(
+ preTimePlanning.SumFlexEndInSeconds, preTimePlanning.SumFlexEnd);
+
///
/// Phase 2 — write the second-precision NettoHours / Flex / SumFlex chain.
///
@@ -491,6 +523,18 @@ public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIn
/// True when there is a preceding planning row (use the running balance);
/// false when this is the first row (reset SumFlexStart to 0).
///
+ ///
+ /// Preferred overload: seeds the chain from
+ /// (null when this is the first row) through
+ /// , so no call site can
+ /// accidentally seed from the raw, usually-zero SumFlexEndInSeconds
+ /// column and silently discard the carried-forward balance.
+ ///
+ public static void ApplyNettoFlexChainSecondPrecision(
+ PlanRegistration pr, PlanRegistration? preTimePlanning)
+ => ApplyNettoFlexChainSecondPrecision(
+ pr, SumFlexEndSecondsWithFallback(preTimePlanning), preTimePlanning != null);
+
public static void ApplyNettoFlexChainSecondPrecision(PlanRegistration pr,
int sumFlexStartInSeconds, bool hasPreTimePlanning)
{
@@ -498,18 +542,11 @@ public static void ApplyNettoFlexChainSecondPrecision(PlanRegistration pr,
pr.NettoHoursInSeconds = (int)nettoSeconds;
pr.NettoHours = nettoSeconds / 3600.0;
- // Punch-clock / scheduled days populate the double PlanHours but leave
- // PlanHoursInSeconds at 0. Fall back to PlanHours * 3600 so flex is
- // computed against the real plan instead of treating it as 0.
- var planHoursSeconds = pr.PlanHoursInSeconds != 0
- ? pr.PlanHoursInSeconds
- : (int)Math.Round(pr.PlanHours * 3600);
- // Production writers populate only the double PaiedOutFlex and leave
- // PaiedOutFlexInSeconds at 0. Fall back to PaiedOutFlex * 3600 so a
- // paid-out flex is subtracted instead of being treated as 0.
- var paiedOutFlexSeconds = pr.PaiedOutFlexInSeconds != 0
- ? pr.PaiedOutFlexInSeconds
- : (int)Math.Round(pr.PaiedOutFlex * 3600);
+ // Punch-clock / scheduled days and production writers populate only the
+ // doubles; the *InSeconds siblings stay 0. See SecondsOrDecimalFallback.
+ var planHoursSeconds = SecondsOrDecimalFallback(pr.PlanHoursInSeconds, pr.PlanHours);
+ var paiedOutFlexSeconds =
+ SecondsOrDecimalFallback(pr.PaiedOutFlexInSeconds, pr.PaiedOutFlex);
// Mirror the flag-off override semantics:
// Flex = (override ? NettoHoursOverride : NettoHours) - PlanHours
@@ -560,12 +597,11 @@ public static async Task UpdatePlanRegistrationsInPer
// Load the message catalog once (no N+1) so each day can resolve its
// localized label without re-querying per row.
var messagesById = await dbContext.Messages.AsNoTracking().ToDictionaryAsync(m => m.Id);
- // Stage 3 tick-exact parity: resolve the UseOneMinuteIntervals mode that
- // was in force when each row was REGISTERED (from AssignedSiteVersions —
- // one query, in-memory lookups) so the Start/Stop display projection
- // below renders tick rows from ids and one-minute rows from stamps,
- // regardless of the site's CURRENT flag. Write/calc forks in this method
- // intentionally keep using dbAssignedSite.UseOneMinuteIntervals.
+ // ONE query, in-memory lookups: resolves the mode that was in force when
+ // each row was REGISTERED. EVERY mode fork in this method reads the
+ // resulting per-row `rowIsOneMinute`, never the site's CURRENT flag —
+ // recomputing closed days under a newly-enabled one-minute flag is what
+ // silently rewrote historical balances. See OneMinuteModeTimeline.
var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, dbAssignedSite);
var toDay = new DateTime(DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, 0, 0, 0);
// var dayOfPayment = toDay.Day >= settingsDayOfPayment
@@ -577,10 +613,8 @@ public static async Task UpdatePlanRegistrationsInPer
var planRegistration = await dbContext.PlanRegistrations.AsTracking().FirstAsync(x => x.Id == plan.Id);
var midnight = new DateTime(planRegistration.Date.Year, planRegistration.Date.Month,
planRegistration.Date.Day, 0, 0, 0);
- // Mode at registration — display-only. The write-time marker (stamped
- // by every Start/Stop-writing save from the site's then-current flag)
- // is authoritative; the AssignedSiteVersions timeline is the fallback
- // for legacy rows written before the marker existed (marker NULL).
+ // Mode at registration: the write-time marker when the row has one,
+ // else the timeline (effective date, else the audit trail).
var rowIsOneMinute = planRegistration.RegisteredUnderOneMinuteIntervals
?? oneMinuteTimeline.WasOneMinuteAt(planRegistration.Date);
@@ -594,7 +628,7 @@ public static async Task UpdatePlanRegistrationsInPer
// the int Id is corrected and StartedAt is backfilled from it.
// When the flag is on but StartedAt is null, fall through to the
// backfill so legacy rows without precise stamps still get one.
- if (dbAssignedSite.UseOneMinuteIntervals && planRegistration.Start1StartedAt.HasValue)
+ if (rowIsOneMinute && planRegistration.Start1StartedAt.HasValue)
{
// Phase 1: precise DateTime stamp wins; do NOT overwrite it
// with the 5-minute snap derived from Start1Id.
@@ -610,7 +644,7 @@ public static async Task UpdatePlanRegistrationsInPer
// FIXME: This is a workaround, it should be removed when the frontend is fixed.
planRegistration.Stop1Id /= 5 + 1;
// Phase 1: same fork as Start1 above for the stop stamp.
- if (dbAssignedSite.UseOneMinuteIntervals && planRegistration.Stop1StoppedAt.HasValue)
+ if (rowIsOneMinute && planRegistration.Stop1StoppedAt.HasValue)
{
// Phase 1: precise DateTime stamp wins; do NOT overwrite it
// with the 5-minute snap derived from Stop1Id.
@@ -679,15 +713,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, run the
- // SumFlex chain in seconds (source of truth) and
- // back-derive doubles. Flag-off path stays byte-identical.
- if (dbAssignedSite.UseOneMinuteIntervals)
+ // Fork on the mode AT REGISTRATION, not the site's current
+ // flag — see OneMinuteModeTimeline for why.
+ if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else if (preTimePlanning != null)
{
@@ -1010,15 +1041,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, run the
- // SumFlex chain in seconds (source of truth) and
- // back-derive doubles. Flag-off path stays byte-identical.
- if (dbAssignedSite.UseOneMinuteIntervals)
+ // Fork on the mode AT REGISTRATION, not the site's current
+ // flag — see OneMinuteModeTimeline for why.
+ if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else if (preTimePlanning != null)
{
@@ -1313,7 +1341,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
Pause5StoppedAt = planRegistration.Pause5StoppedAt
};
- planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, dbAssignedSite.UseOneMinuteIntervals);
+ planningModel.PauseMinutes += AggregatePauseMinutes(planRegistration, rowIsOneMinute);
// planningModel.PauseMinutes = planningModel.PauseMinutes > 0 ? planningModel.PauseMinutes - 5 : 0;
@@ -1378,6 +1406,10 @@ DateTime dayOfPayment
{
return planRegistration;
}
+ // Mode AT REGISTRATION for this row, never the site's current flag —
+ // see OneMinuteModeTimeline.
+ var rowIsOneMinute = await OneMinuteModeTimeline.ResolveRowModeAsync(
+ dbContext, dbAssignedSite, planRegistration);
var tainted = false;
// foreach (var plan in planningsInPeriod)
// {
@@ -1434,15 +1466,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, run the
- // SumFlex chain in seconds (source of truth) and
- // back-derive doubles. Flag-off path stays byte-identical.
- if (dbAssignedSite.UseOneMinuteIntervals)
+ // Fork on the mode AT REGISTRATION, not the site's current
+ // flag — see OneMinuteModeTimeline for why.
+ if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else if (preTimePlanning != null)
{
@@ -1752,15 +1781,12 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, run the
- // SumFlex chain in seconds (source of truth) and
- // back-derive doubles. Flag-off path stays byte-identical.
- if (dbAssignedSite.UseOneMinuteIntervals)
+ // Fork on the mode AT REGISTRATION, not the site's current
+ // flag — see OneMinuteModeTimeline for why.
+ if (rowIsOneMinute)
{
ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else if (preTimePlanning != null)
{
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
index dfdcb8dd..a67e2c85 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs
@@ -42,6 +42,7 @@ namespace TimePlanning.Pn.Services.TimePlanningFlexService;
using Microting.eFormApi.BasePn.Infrastructure.Models.Common;
using Microting.TimePlanningBase.Infrastructure.Data;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
+using TimePlanning.Pn.Infrastructure.Helpers;
using TimePlanningLocalizationService;
///
@@ -248,19 +249,43 @@ private async Task UpdatePlanning(PlanRegistration planRegistration,
planRegistration.CommentOfficeAll = model.CommentOfficeAll;
planRegistration.CommentOffice = model.CommentOffice;
+ var assignedSite = await dbContext.AssignedSites
+ .AsNoTracking()
+ .Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
+ .FirstOrDefaultAsync(x => x.SiteId == planRegistration.SdkSitId);
+
// PaiedOutFlexInSeconds is the source the flag-on flex chain
// (PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision /
// TimePlanningWorkingHoursService.ApplyRunningFlexChain) subtracts. Only ever
// updating the legacy double below would leave that column stale, so keep it
// in lockstep here too. Old value falls back to the double the same way those
// chains do, since this row may itself have only ever had the double set.
- var oldPaiedOutFlexSeconds = planRegistration.PaiedOutFlexInSeconds != 0
- ? planRegistration.PaiedOutFlexInSeconds
- : (int)Math.Round(planRegistration.PaiedOutFlex * 3600);
+ var oldPaiedOutFlexSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback(
+ planRegistration.PaiedOutFlexInSeconds, planRegistration.PaiedOutFlex);
var newPaiedOutFlexSeconds = (int)Math.Round(model.PaidOutFlex * 3600);
+ // SumFlexEndInSeconds is the source of truth ONLY for rows registered
+ // under one-minute mode; on 5-minute rows it is deliberately left at 0
+ // (the decimal SumFlexEnd is the balance there), and ops relies on that
+ // zero as a forensic signal. Writing an unconditional delta here both
+ // corrupted 5-minute rows and — because the column is 0 on ~97% of rows
+ // while the decimal holds the real balance — produced a NEGATIVE value.
+ // The seed must be read BEFORE the decimal SumFlexEnd is adjusted below,
+ // or the fallback would pick up the already-adjusted balance and apply
+ // the payout delta twice.
+ var rowIsOneMinute = await OneMinuteModeTimeline.ResolveRowModeAsync(
+ dbContext, assignedSite, planRegistration);
+ var oldSumFlexEndSeconds =
+ PlanRegistrationHelper.SumFlexEndSecondsWithFallback(planRegistration);
+
planRegistration.SumFlexEnd += planRegistration.PaiedOutFlex - model.PaidOutFlex;
- planRegistration.SumFlexEndInSeconds += oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds;
+
+ if (rowIsOneMinute)
+ {
+ planRegistration.SumFlexEndInSeconds =
+ oldSumFlexEndSeconds + oldPaiedOutFlexSeconds - newPaiedOutFlexSeconds;
+ }
+
planRegistration.PaiedOutFlex = model.PaidOutFlex;
planRegistration.PaiedOutFlexInSeconds = newPaiedOutFlexSeconds;
planRegistration.UpdatedByUserId = userService.UserId;
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs
index b0e29e04..9ae36c00 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs
@@ -1076,9 +1076,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planning,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planning, preTimePlanning);
}
else
{
@@ -1130,6 +1128,9 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderBy(x => x.Date)
.ToList();
+ // ONE query for the whole cascade below — never per row.
+ var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
+
foreach (var planningAfterThisPlanning in planningsAfterThisPlanning)
{
var preTimePlanningAfterThisPlanning =
@@ -1140,15 +1141,13 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, replay the
- // SumFlex chain through subsequent days using *InSeconds as
- // the source of truth so accumulated rounding does not drift.
- if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
+ // These are OTHER, already-registered rows, so fork on the mode
+ // AT REGISTRATION — see OneMinuteModeTimeline.
+ if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals
+ ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planningAfterThisPlanning,
- preTimePlanningAfterThisPlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanningAfterThisPlanning != null);
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
}
else if (preTimePlanningAfterThisPlanning != null)
{
@@ -1471,9 +1470,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planning,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planning, preTimePlanning);
}
else
{
@@ -1504,6 +1501,9 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderBy(x => x.Date)
.ToList();
+ // ONE query for the whole cascade below — never per row.
+ var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
+
foreach (var planningAfterThisPlanning in planningsAfterThisPlanning)
{
var preTimePlanningAfterThisPlanning =
@@ -1514,15 +1514,13 @@ await dbContext.PlanRegistrations.AsNoTracking()
.OrderByDescending(x => x.Date)
.FirstOrDefaultAsync();
- // Phase 2: when UseOneMinuteIntervals is on, replay the
- // SumFlex chain through subsequent days using *InSeconds as
- // the source of truth so accumulated rounding does not drift.
- if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
+ // These are OTHER, already-registered rows, so fork on the mode
+ // AT REGISTRATION — see OneMinuteModeTimeline.
+ if (planningAfterThisPlanning.RegisteredUnderOneMinuteIntervals
+ ?? cascadeTimeline.WasOneMinuteAt(planningAfterThisPlanning.Date))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planningAfterThisPlanning,
- preTimePlanningAfterThisPlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanningAfterThisPlanning != null);
+ planningAfterThisPlanning, preTimePlanningAfterThisPlanning);
}
else if (preTimePlanningAfterThisPlanning != null)
{
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs
index 7bec78d5..6264e62f 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs
@@ -982,6 +982,13 @@ public async Task UpdateAssignedSite(Infrastructure.Models.Sett
dbAssignedSite.AllowPersonalTimeRegistration = site.AllowPersonalTimeRegistration;
dbAssignedSite.AllowAcceptOfPlannedHours = site.AllowAcceptOfPlannedHours;
dbAssignedSite.Resigned = site.Resigned;
+ // Record WHEN one-minute intervals took effect, so every later flex
+ // recomputation keeps pre-switch days on 5-minute rules instead of
+ // restating already-closed SumFlexEnd balances at one-minute precision.
+ // MUST run BEFORE the one-way OR on the next line — see the helper.
+ OneMinuteModeTimeline.StampEffectiveDateOnEnable(
+ dbAssignedSite, site.UseOneMinuteIntervals, DateTime.UtcNow);
+
dbAssignedSite.UseOneMinuteIntervals = dbAssignedSite.UseOneMinuteIntervals || site.UseOneMinuteIntervals;
dbAssignedSite.UsePunchClock = site.UsePunchClock;
dbAssignedSite.UseDetailedPauseEditing = site.UseDetailedPauseEditing;
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
index ed82fbd4..2987148c 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs
@@ -98,12 +98,9 @@ public async Task>> Inde
.AsNoTracking()
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.FirstOrDefaultAsync(x => x.SiteId == model.SiteId);
- var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false;
// Stage 3 tick-exact parity: per-row mode-at-registration from the
// AssignedSiteVersions audit trail (one query; in-memory lookups).
- var oneMinuteTimeline = assignedSite != null
- ? await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite)
- : new OneMinuteModeTimeline(false, Array.Empty<(bool, DateTime)>());
+ var oneMinuteTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
var timePlanningRequest = dbContext.PlanRegistrations
.AsNoTracking()
@@ -270,10 +267,13 @@ public async Task>> Inde
continue;
}
+ // Mode AT REGISTRATION — see OneMinuteModeTimeline.
+ var pauseRowIsOneMinute = tp.RegisteredUnderOneMinuteIntervals
+ ?? oneMinuteTimeline.WasOneMinuteAt(tp.Date);
tp.Shift1PauseMinutes =
- PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 1, useOneMinuteIntervals) / 60;
+ PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 1, pauseRowIsOneMinute) / 60;
tp.Shift2PauseMinutes =
- PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 2, useOneMinuteIntervals) / 60;
+ PlanRegistrationHelper.ComputeShiftPauseSeconds(pauseRow, 2, pauseRowIsOneMinute) / 60;
}
}
@@ -287,6 +287,9 @@ public async Task>> Inde
if (lastPlanning != null)
{
+ // Mode AT REGISTRATION for the carried-over previous-day row.
+ var lastPlanningIsOneMinute = lastPlanning.RegisteredUnderOneMinuteIntervals
+ ?? oneMinuteTimeline.WasOneMinuteAt(lastPlanning.Date);
// lastPlanning.Date = new DateTime(lastPlanning.Date.Year, lastPlanning.Date.Month, lastPlanning.Date.Day, 0, 0, 0);
@@ -315,10 +318,10 @@ public async Task>> Inde
// materialized PlanRegistration already in scope, so this reuses
// ComputeShiftPauseSeconds with no extra query (no N+1).
Shift1PauseMinutes = lastPlanning != null
- ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 1, useOneMinuteIntervals) / 60
+ ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 1, lastPlanningIsOneMinute) / 60
: 0,
Shift2PauseMinutes = lastPlanning != null
- ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 2, useOneMinuteIntervals) / 60
+ ? PlanRegistrationHelper.ComputeShiftPauseSeconds(lastPlanning, 2, lastPlanningIsOneMinute) / 60
: 0,
Shift3Start = lastPlanning?.Start3Id,
Shift3Stop = lastPlanning?.Stop3Id,
@@ -348,7 +351,7 @@ public async Task>> Inde
NettoHoursInSeconds = lastPlanning?.NettoHoursInSeconds ?? 0,
FlexInSeconds = lastPlanning?.FlexInSeconds ?? 0,
SumFlexStartInSeconds = lastPlanning?.SumFlexStartInSeconds ?? 0,
- SumFlexEndInSeconds = lastPlanning?.SumFlexEndInSeconds ?? 0,
+ SumFlexEndInSeconds = PlanRegistrationHelper.SumFlexEndSecondsWithFallback(lastPlanning),
PaiedOutFlexInSeconds = lastPlanning?.PaiedOutFlexInSeconds ?? 0,
Message = lastPlanning?.MessageId,
CommentWorker = lastPlanning?.WorkerComment?.Replace("\r", "
"),
@@ -423,8 +426,10 @@ public async Task>> Inde
// Single source of truth for the running flex balance rendered by
// both this web grid AND the mobile period-status hero
- // (CalculateHoursSummary) — see ApplyRunningFlexChain.
- ApplyRunningFlexChain(timePlannings, useOneMinuteIntervals);
+ // (CalculateHoursSummary) — see ApplyRunningFlexChain. Forked PER ROW
+ // on the mode at registration so a period spanning a one-minute flip
+ // keeps recomputing its pre-flip days under 5-minute rules.
+ ApplyRunningFlexChain(timePlannings, oneMinuteTimeline);
return new OperationDataResult>(
true,
@@ -454,7 +459,8 @@ public async Task CreateUpdate(TimePlanningWorkingHoursUpdateCr
var assignedSite = await dbContext.AssignedSites
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.FirstOrDefaultAsync(x => x.SiteId == model.SiteId);
- var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false;
+ // ONE query for the whole cascade below — never per row.
+ var cascadeTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
var first = true;
foreach (var planning in model.Plannings)
{
@@ -505,12 +511,13 @@ await dbContext.PlanRegistrations.AsNoTracking()
// so the double SumFlexEnd drifted from the seconds chain and the
// mobile summary (which read the double) disagreed with the web
// grid (which recomputes from seconds).
- if (useOneMinuteIntervals)
+ // Mode AT REGISTRATION for THIS later row — see
+ // OneMinuteModeTimeline.
+ if (planRegistration.RegisteredUnderOneMinuteIntervals
+ ?? cascadeTimeline.WasOneMinuteAt(planRegistration.Date))
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else
{
@@ -879,7 +886,9 @@ public async Task> CalculateH
.AsNoTracking()
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
.FirstOrDefaultAsync(x => x.SiteId == sdkSite.MicrotingUid);
- var useOneMinuteIntervals = assignedSite?.UseOneMinuteIntervals ?? false;
+ // Per-row mode-at-registration, mirroring Index so the mobile hero and
+ // the web grid stay in agreement across a one-minute flip.
+ var summaryTimeline = await OneMinuteModeTimeline.BuildAsync(dbContext, assignedSite);
// Anchor = last non-removed planning strictly before the period; carries
// the opening balance into the chain. Matches Index's prePlanning anchor
@@ -904,7 +913,8 @@ public async Task> CalculateH
FlexInSeconds = x.FlexInSeconds,
SumFlexStartInSeconds = x.SumFlexStartInSeconds,
SumFlexEndInSeconds = x.SumFlexEndInSeconds,
- PaiedOutFlexInSeconds = x.PaiedOutFlexInSeconds
+ PaiedOutFlexInSeconds = x.PaiedOutFlexInSeconds,
+ RegisteredUnderOneMinuteIntervals = x.RegisteredUnderOneMinuteIntervals
};
var inRangeRows = planRegistrations
@@ -919,7 +929,7 @@ public async Task> CalculateH
}
chainRows.AddRange(inRangeRows);
- ApplyRunningFlexChain(chainRows, useOneMinuteIntervals);
+ ApplyRunningFlexChain(chainRows, summaryTimeline);
// Difference = the last in-range day's recomputed SumFlexEnd (seconds
// chain), NOT a raw column read. When the period itself has no rows,
@@ -978,73 +988,83 @@ private static int PaiedOutFlexSecondsWithFallback(TimePlanningWorkingHoursModel
double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture) * 3600);
}
+ ///
+ /// The legacy double paid-out-flex operand: the culture-formatted
+ /// PaidOutFlex string parsed to hours (0 when absent). Distinct from
+ /// , which prefers the integer
+ /// seconds column — the 5-minute branch must keep reading only the string.
+ ///
+ private static double PaidOutFlexHours(TimePlanningWorkingHoursModel model)
+ => string.IsNullOrEmpty(model.PaidOutFlex)
+ ? 0
+ : double.Parse(model.PaidOutFlex.Replace(",", "."), CultureInfo.InvariantCulture);
+
///
/// Applies the running flex-balance chain over an ordered-by-date list of
/// working-hours rows. Single source of truth for the flex balance rendered
/// by both (web grid) and
/// (mobile period-status hero) so the two never disagree.
///
- /// For UseOneMinuteIntervals sites the chain runs in the integer
- /// *InSeconds columns (the source of truth) and back-derives the legacy
- /// double SumFlex* fields via /3600.0; otherwise it runs in the
- /// legacy rounded doubles. Behaviour is byte-identical to the loop previously
- /// inlined in .
+ /// The mode is resolved PER ROW — never from the site's current flag — via
+ /// , so a period spanning a one-minute
+ /// flip keeps recomputing its pre-flip days under 5-minute rules instead of
+ /// silently restating closed balances.
+ ///
+ /// For a one-minute row the chain runs in the integer *InSeconds columns
+ /// (the source of truth) and back-derives the legacy double SumFlex*
+ /// fields via /3600.0; a 5-minute row runs in the legacy rounded doubles
+ /// and its *InSeconds DTO fields are deliberately left untouched (the
+ /// flag-off response stays byte-identical, and ops reads a zero there as the
+ /// signal that the row never ran in one-minute mode). BOTH running accumulators
+ /// are nonetheless kept in lockstep after every row, so the balance carries
+ /// correctly across a mode boundary in either direction.
///
- private void ApplyRunningFlexChain(
- List timePlannings, bool useOneMinuteIntervals)
+ internal void ApplyRunningFlexChain(
+ List timePlannings, OneMinuteModeTimeline timeline)
{
var j = 0;
double sumFlexEnd = 0;
- // Phase 2: parallel running balance in seconds for flag-on chain.
- int sumFlexEndInSeconds = 0;
- //double SumFlexStart = 0;
- foreach (var timePlanningWorkingHoursModel in timePlannings)
+ // Phase 2: parallel running balance in seconds for one-minute rows.
+ var sumFlexEndInSeconds = 0;
+ foreach (var row in timePlannings)
{
+ var isOneMinuteRow = row.RegisteredUnderOneMinuteIntervals
+ ?? timeline.WasOneMinuteAt(row.Date);
if (j == 0)
{
- if (useOneMinuteIntervals)
+ if (isOneMinuteRow)
{
- // Phase 2: chain in seconds; back-derive doubles via /3600.0.
- timePlanningWorkingHoursModel.SumFlexStartInSeconds =
- timePlanningWorkingHoursModel.SumFlexStartInSeconds;
- timePlanningWorkingHoursModel.SumFlexStart =
- timePlanningWorkingHoursModel.SumFlexStartInSeconds / 3600.0;
- timePlanningWorkingHoursModel.SumFlexEndInSeconds =
- timePlanningWorkingHoursModel.SumFlexStartInSeconds
- + timePlanningWorkingHoursModel.FlexInSeconds
- - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel);
- timePlanningWorkingHoursModel.SumFlexEnd =
- timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0;
- sumFlexEndInSeconds = timePlanningWorkingHoursModel.SumFlexEndInSeconds;
- sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd;
+ // The anchor's opening balance falls back to the decimal
+ // SumFlexStart when the seconds column is still 0 — otherwise
+ // the whole carried-forward balance is dropped at the head of
+ // the chain. See PlanRegistrationHelper.SecondsOrDecimalFallback.
+ row.SumFlexStartInSeconds = PlanRegistrationHelper.SecondsOrDecimalFallback(
+ row.SumFlexStartInSeconds, row.SumFlexStart);
+ row.SumFlexStart = row.SumFlexStartInSeconds / 3600.0;
+ row.SumFlexEndInSeconds = row.SumFlexStartInSeconds
+ + row.FlexInSeconds
+ - PaiedOutFlexSecondsWithFallback(row);
+ row.SumFlexEnd = row.SumFlexEndInSeconds / 3600.0;
}
else
{
- timePlanningWorkingHoursModel.SumFlexStart =
- Math.Round(timePlanningWorkingHoursModel.SumFlexStart, 2);
- timePlanningWorkingHoursModel.SumFlexEnd = Math.Round(
- timePlanningWorkingHoursModel.SumFlexStart + timePlanningWorkingHoursModel.FlexHours -
- (string.IsNullOrEmpty(timePlanningWorkingHoursModel.PaidOutFlex)
- ? 0
- : double.Parse(timePlanningWorkingHoursModel.PaidOutFlex.Replace(",", "."),
- CultureInfo.InvariantCulture)), 2);
- sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd;
+ row.SumFlexStart = Math.Round(row.SumFlexStart, 2);
+ row.SumFlexEnd = Math.Round(
+ row.SumFlexStart + row.FlexHours - PaidOutFlexHours(row), 2);
}
}
else
{
- if (useOneMinuteIntervals)
+ if (isOneMinuteRow)
{
- timePlanningWorkingHoursModel.SumFlexStartInSeconds = sumFlexEndInSeconds;
- timePlanningWorkingHoursModel.SumFlexStart = sumFlexEndInSeconds / 3600.0;
+ row.SumFlexStartInSeconds = sumFlexEndInSeconds;
+ row.SumFlexStart = sumFlexEndInSeconds / 3600.0;
try
{
- timePlanningWorkingHoursModel.SumFlexEndInSeconds =
- timePlanningWorkingHoursModel.SumFlexStartInSeconds
- + timePlanningWorkingHoursModel.FlexInSeconds
- - PaiedOutFlexSecondsWithFallback(timePlanningWorkingHoursModel);
- timePlanningWorkingHoursModel.SumFlexEnd =
- timePlanningWorkingHoursModel.SumFlexEndInSeconds / 3600.0;
+ row.SumFlexEndInSeconds = row.SumFlexStartInSeconds
+ + row.FlexInSeconds
+ - PaiedOutFlexSecondsWithFallback(row);
+ row.SumFlexEnd = row.SumFlexEndInSeconds / 3600.0;
}
catch (Exception e)
{
@@ -1052,21 +1072,14 @@ private void ApplyRunningFlexChain(
logger.LogError(e.Message);
logger.LogTrace(e.StackTrace);
}
-
- sumFlexEndInSeconds = timePlanningWorkingHoursModel.SumFlexEndInSeconds;
- sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd;
}
else
{
- timePlanningWorkingHoursModel.SumFlexStart = sumFlexEnd;
+ row.SumFlexStart = sumFlexEnd;
try
{
- timePlanningWorkingHoursModel.SumFlexEnd = Math.Round(
- timePlanningWorkingHoursModel.SumFlexStart + timePlanningWorkingHoursModel.FlexHours -
- (string.IsNullOrEmpty(timePlanningWorkingHoursModel.PaidOutFlex)
- ? 0
- : double.Parse(timePlanningWorkingHoursModel.PaidOutFlex.Replace(",", "."),
- CultureInfo.InvariantCulture)), 2);
+ row.SumFlexEnd = Math.Round(
+ row.SumFlexStart + row.FlexHours - PaidOutFlexHours(row), 2);
}
catch (Exception e)
{
@@ -1074,11 +1087,15 @@ private void ApplyRunningFlexChain(
logger.LogError(e.Message);
logger.LogTrace(e.StackTrace);
}
-
- sumFlexEnd = timePlanningWorkingHoursModel.SumFlexEnd;
}
}
+ // One tail for all four branches: both accumulators advance together
+ // so the next row can chain off this one whichever mode it is in.
+ sumFlexEnd = row.SumFlexEnd;
+ sumFlexEndInSeconds = isOneMinuteRow
+ ? row.SumFlexEndInSeconds
+ : (int)Math.Round(sumFlexEnd * 3600);
j++;
}
}
@@ -1628,9 +1645,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else
{
@@ -1931,9 +1946,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else
{
@@ -2293,9 +2306,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else
{
@@ -2585,9 +2596,7 @@ await dbContext.PlanRegistrations.AsNoTracking()
if (assignedSite != null && assignedSite.UseOneMinuteIntervals)
{
PlanRegistrationHelper.ApplyNettoFlexChainSecondPrecision(
- planRegistration,
- preTimePlanning?.SumFlexEndInSeconds ?? 0,
- preTimePlanning != null);
+ planRegistration, preTimePlanning);
}
else
{
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj
index 77701847..343d7188 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj
@@ -33,7 +33,7 @@
-
+