diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
index 3f0f8322..efea0ce2 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs
@@ -974,24 +974,238 @@ public void AggregatePauseMinutes_LegacyMode_15MinPauseReturns15()
}
///
- /// When UseOneMinuteIntervals is on, the legacy Pause*Id field must be
- /// ignored: a row with Pause1Id = 4 (legacy 15 min) but no DateTime stamps
- /// is treated as zero pause.
+ /// When UseOneMinuteIntervals is on but no stamp pairs are populated,
+ /// the helper falls back to the legacy 5-minute-tick formula on the 5
+ /// main slots. This covers older flag-on rows written before stamp-pair
+ /// pauses existed. Pause1Id = 4 → (4 * 5) - 5 = 15 minutes via fallback.
///
[Test]
- public void AggregatePauseMinutes_OneMinuteInterval_NullStampsContributeZero()
+ public void AggregatePauseMinutes_OneMinuteInterval_NoStampsFallsBackToLegacyTicks()
{
// Arrange
var pr = new PlanRegistration
{
- Pause1Id = 4, // legacy 15-min pause; flag-on path must ignore this
+ Pause1Id = 4, // legacy 15-min pause; flag-on path falls back when no stamps
// Pause1StartedAt / Pause1StoppedAt left null
};
// Act
var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+ // Assert — legacy fallback now applies since no stamp pairs are populated.
+ Assert.That(result, Is.EqualTo(15));
+ }
+
+ ///
+ /// Customer 855 root-cause regression: with UseOneMinuteIntervals = true,
+ /// a 3-minute pause stamped in a SUB-SLOT (Pause10StartedAt / Pause10StoppedAt)
+ /// rather than the main Pause1 slot must aggregate to 3, not 0. Before the
+ /// 31-pair sub-slot scan, the backend overview rendered "Samlet pause: 00:00"
+ /// while the edit dialog (which already scanned sub-slots) showed Pause: 00:03.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot()
+ {
+ // Arrange
+ var someDate = new DateTime(2026, 5, 7, 0, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause10StartedAt = someDate.AddHours(12),
+ Pause10StoppedAt = someDate.AddHours(12).AddMinutes(3),
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(3));
+ }
+
+ ///
+ /// Shift-1 multi-pause case: pauses in BOTH the main slot (Pause1) and a
+ /// sub-slot (Pause11) must sum (4 + 6 = 10). Proves the loop visits all
+ /// populated pairs, not just the first.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_SumsPause1AndPause11()
+ {
+ // Arrange
+ var someDate = new DateTime(2026, 5, 7, 0, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause1StartedAt = someDate.AddHours(10),
+ Pause1StoppedAt = someDate.AddHours(10).AddMinutes(4),
+ Pause11StartedAt = someDate.AddHours(11),
+ Pause11StoppedAt = someDate.AddHours(11).AddMinutes(6),
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(10));
+ }
+
+ ///
+ /// Shift-2 sub-slot case: pauses in Pause2 main slot AND Pause20 sub-slot
+ /// must sum (5 + 8 = 13). Mirrors the shift-1 multi-pause case for shift 2.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_SumsPause2AndPause20()
+ {
+ // Arrange
+ var someDate = new DateTime(2026, 5, 7, 0, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause2StartedAt = someDate.AddHours(13),
+ Pause2StoppedAt = someDate.AddHours(13).AddMinutes(5),
+ Pause20StartedAt = someDate.AddHours(15),
+ Pause20StoppedAt = someDate.AddHours(15).AddMinutes(8),
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(13));
+ }
+
+ ///
+ /// Shift 3 single-slot case: a pause stamped in Pause3StartedAt/Pause3StoppedAt
+ /// must aggregate to its true minute delta when the flag is on.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_Pause3SingleSlot()
+ {
+ // Arrange
+ var someDate = new DateTime(2026, 5, 7, 0, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause3StartedAt = someDate.AddHours(17),
+ Pause3StoppedAt = someDate.AddHours(17).AddMinutes(9),
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(9));
+ }
+
+ ///
+ /// Flag-on legacy fallback: a row with no stamps but Pause1Id = 2 (the legacy
+ /// 5-min companion) must return 5 minutes via the (Pause1Id * 5) - 5 formula.
+ /// Covers the older flag-on rows that pre-date the stamp-pair multi-pause flow.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_NoStamps_Pause1Id2_ReturnsFiveMinutes()
+ {
+ // Arrange
+ var pr = new PlanRegistration
+ {
+ Pause1Id = 2, // legacy 5-min companion: (2 * 5) - 5 = 5
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(5));
+ }
+
+ ///
+ /// Flag-on, every stamp and every Pause*Id is null/zero — the helper must
+ /// return 0. Guards against accidental defaults leaking nonzero output.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_EverythingEmptyReturnsZero()
+ {
+ // Arrange
+ var pr = new PlanRegistration();
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert
+ Assert.That(result, Is.EqualTo(0));
+ }
+
+ ///
+ /// Flag-on, stamps are populated but the duration is zero
+ /// (StartedAt == StoppedAt). Even though the duration sum is 0, the
+ /// helper must NOT fall back to legacy ticks — the worker did stamp a
+ /// pause and the intended display is 0 minutes. Pause1Id = 4 (legacy
+ /// 15 min) must be ignored.
+ ///
+ /// Guards against the fallback being gated on totalSeconds == 0
+ /// instead of "no stamp observed" (Copilot review on PR #1575).
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_ZeroDurationStampDoesNotFallBackToLegacy()
+ {
+ // Arrange — stamps describe a 0-min pause (start == stop).
+ var someDate = new DateTime(2026, 5, 7, 12, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause1StartedAt = someDate,
+ Pause1StoppedAt = someDate,
+ Pause1Id = 4, // legacy 15 min — must be ignored because stamps were observed.
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
+ // Assert — stamps were observed, so the (0-min) stamp result wins
+ // over the legacy fallback that would otherwise return 15.
+ Assert.That(result, Is.EqualTo(0));
+ }
+
+ ///
+ /// Flag-on, stamps are populated but the duration is NEGATIVE
+ /// (StoppedAt < StartedAt — clock skew or data corruption). Same
+ /// contract as the zero-duration case: stamps were observed, so the
+ /// legacy fallback must NOT trigger.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_OneMinuteInterval_NegativeDurationStampDoesNotFallBackToLegacy()
+ {
+ // Arrange — stamps describe an invalid pause (stop before start).
+ var someDate = new DateTime(2026, 5, 7, 12, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause1StartedAt = someDate.AddMinutes(5),
+ Pause1StoppedAt = someDate,
+ Pause1Id = 4, // legacy 15 min — must be ignored.
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: true);
+
// Assert
Assert.That(result, Is.EqualTo(0));
}
+
+ ///
+ /// Flag-off parity: stamps populated and legacy Pause1Id both present.
+ /// The flag-off branch must still return only the legacy tick result
+ /// (15 here) — proves the new flag-on logic did not touch the flag-off path.
+ ///
+ [Test]
+ public void AggregatePauseMinutes_LegacyMode_StampsPopulated_StillReturnsLegacyTicks()
+ {
+ // Arrange — stamps describe a 3-min pause, Pause1Id = 4 is a legacy 15-min companion.
+ var someDate = new DateTime(2026, 5, 7, 0, 0, 0);
+ var pr = new PlanRegistration
+ {
+ Pause1StartedAt = someDate.AddHours(12),
+ Pause1StoppedAt = someDate.AddHours(12).AddMinutes(3),
+ Pause1Id = 4, // legacy 15 min
+ };
+
+ // Act
+ var result = PlanRegistrationHelper.AggregatePauseMinutes(pr, useOneMinuteIntervals: false);
+
+ // Assert — flag-off branch ignores stamps and uses legacy ticks only.
+ Assert.That(result, Is.EqualTo(15));
+ }
}
\ No newline at end of file
diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs
index b9a43b3f..aceff5af 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@
using Microting.eFormApi.BasePn.Infrastructure.Database.Entities;
using Microting.TimePlanningBase.Infrastructure.Data.Entities;
using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite;
+using SdkSite = Microting.eForm.Infrastructure.Data.Entities.Site;
using NSubstitute;
using NUnit.Framework;
using TimePlanning.Pn.Infrastructure.Helpers;
@@ -363,4 +365,164 @@ public async Task Update_NullModel_ReturnsFailureWithoutException()
"Localization key must match the existing catch-block fallback so " +
"the front-end's error surfacing is unchanged.");
}
+
+ ///
+ /// Service-level regression for PR #1575 (commits 6ebc5fe6 + 43da9f2e). When
+ /// an AssignedSite has UseOneMinuteIntervals = true and a PlanRegistration's
+ /// only pause sits in a SUB-SLOT pair (e.g. Pause10StartedAt/Pause10StoppedAt),
+ /// the per-day model the plannings overview consumes must surface PauseMinutes
+ /// from that stamp. Before the fix the backend only iterated Pause1..Pause5
+ /// and reported Samlet pause 00:00 while the edit dialog (which already
+ /// scanned sub-slots) showed 00:03.
+ ///
+ /// Exercises the exact call chain Index() takes
+ /// Index() → PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod()
+ /// → PlanRegistrationHelper.AggregatePauseMinutes()
+ /// against a real TimePlanningPnDbContext, so a refactor that drops the
+ /// AggregatePauseMinutes call from UpdatePlanRegistrationsInPeriod fails
+ /// here even though the helper-level unit tests in
+ /// PlanRegistrationHelperTests.cs still pass.
+ ///
+ /// (The full Index() path additionally requires real BaseDbContext.Users +
+ /// sdkDbContext.Sites seeding for the admin-gate + per-site lookup; that
+ /// fixture is the same gap currently [Ignore]d across the suite — see
+ /// SettingsServiceExtendedTests.cs for the rationale. The call-site test
+ /// here is the smallest unit that still observes the bug end-to-end.)
+ ///
+ [Test]
+ public async Task Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly()
+ {
+ // Arrange — flag ON site, planning row with ONLY a sub-slot pause
+ // (Pause10*). Pause1Id stays 0 so we prove the legacy fallback isn't
+ // the thing producing the 3.
+ var assignedSite = new AssignedSiteEntity
+ {
+ SiteId = 904,
+ UseOneMinuteIntervals = true,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1
+ };
+ await assignedSite.Create(TimePlanningPnDbContext);
+
+ var date = new DateTime(2026, 5, 14, 0, 0, 0, DateTimeKind.Utc);
+ var planning = new PlanRegistration
+ {
+ SdkSitId = 904,
+ Date = date,
+ // Sub-slot pause: 12:00 → 12:03, no Pause1*.
+ Pause10StartedAt = date.AddHours(12),
+ Pause10StoppedAt = date.AddHours(12).AddMinutes(3),
+ Pause1Id = 0,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1
+ };
+ await planning.Create(TimePlanningPnDbContext);
+
+ // Re-pull the registration the same shape Index() does (Id + Date only —
+ // UpdatePlanRegistrationsInPeriod re-fetches the full row internally).
+ var planningsInPeriod = await TimePlanningPnDbContext.PlanRegistrations
+ .AsNoTracking()
+ .Where(x => x.SdkSitId == 904)
+ .Select(x => new PlanRegistration { Id = x.Id, Date = x.Date })
+ .ToListAsync();
+
+ var siteModel = new TimePlanningPlanningModel
+ {
+ SiteId = 904,
+ SiteName = "Test site 904",
+ UseOneMinuteIntervals = true,
+ PlanningPrDayModels = new List()
+ };
+
+ var sdkSite = new SdkSite { Name = "Test site 904", MicrotingUid = 904 };
+
+ // Act — call the exact helper Index() invokes to build the per-day models.
+ var result = await PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod(
+ planningsInPeriod,
+ siteModel,
+ TimePlanningPnDbContext,
+ assignedSite,
+ Substitute.For>(),
+ sdkSite,
+ date.AddDays(-1),
+ date.AddDays(1),
+ _options);
+
+ // Assert — per-day model for 2026-05-14 surfaces PauseMinutes = 3 from
+ // the sub-slot stamp pair, NOT 0 (the pre-fix observable bug).
+ var prDay = result.PlanningPrDayModels.Single(x => x.Date.Date == date.Date);
+ Assert.That(prDay.PauseMinutes, Is.EqualTo(3.0),
+ "Sub-slot pause stamps (Pause10*) must aggregate when UseOneMinuteIntervals = true.");
+ }
+
+ ///
+ /// Negative companion to Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly:
+ /// when UseOneMinuteIntervals = false the same row's PauseMinutes must be 0,
+ /// because the legacy 5-minute-grid path doesn't scan sub-slot DateTime
+ /// stamps — it falls back to (Pause1Id - 1) * 5, and Pause1Id = 0 yields 0.
+ ///
+ /// This pair (flag-on / flag-off with identical seed) proves the flag itself
+ /// is the toggle that flips the aggregation path through the service.
+ ///
+ [Test]
+ public async Task Index_LegacyFiveMinuteFlag_WithSubSlotPauseStamps_PauseMinutesIsZero()
+ {
+ // Arrange — flag OFF, same sub-slot pause seed as the positive test.
+ var assignedSite = new AssignedSiteEntity
+ {
+ SiteId = 905,
+ UseOneMinuteIntervals = false,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1
+ };
+ await assignedSite.Create(TimePlanningPnDbContext);
+
+ var date = new DateTime(2026, 5, 14, 0, 0, 0, DateTimeKind.Utc);
+ var planning = new PlanRegistration
+ {
+ SdkSitId = 905,
+ Date = date,
+ Pause10StartedAt = date.AddHours(12),
+ Pause10StoppedAt = date.AddHours(12).AddMinutes(3),
+ Pause1Id = 0,
+ CreatedByUserId = 1,
+ UpdatedByUserId = 1
+ };
+ await planning.Create(TimePlanningPnDbContext);
+
+ var planningsInPeriod = await TimePlanningPnDbContext.PlanRegistrations
+ .AsNoTracking()
+ .Where(x => x.SdkSitId == 905)
+ .Select(x => new PlanRegistration { Id = x.Id, Date = x.Date })
+ .ToListAsync();
+
+ var siteModel = new TimePlanningPlanningModel
+ {
+ SiteId = 905,
+ SiteName = "Test site 905",
+ UseOneMinuteIntervals = false,
+ PlanningPrDayModels = new List()
+ };
+
+ var sdkSite = new SdkSite { Name = "Test site 905", MicrotingUid = 905 };
+
+ // Act
+ var result = await PlanRegistrationHelper.UpdatePlanRegistrationsInPeriod(
+ planningsInPeriod,
+ siteModel,
+ TimePlanningPnDbContext,
+ assignedSite,
+ Substitute.For>(),
+ sdkSite,
+ date.AddDays(-1),
+ date.AddDays(1),
+ _options);
+
+ // Assert — flag-off path can't see sub-slot stamps. With Pause1Id = 0
+ // the legacy formula (Pause1Id - 1) * 5 yields 0 (the function returns
+ // 0 when Pause1Id is zero rather than going negative).
+ var prDay = result.PlanningPrDayModels.Single(x => x.Date.Date == date.Date);
+ Assert.That(prDay.PauseMinutes, Is.EqualTo(0.0),
+ "Legacy flag-off path must NOT pick up sub-slot pause stamps.");
+ }
}
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 64d5ebd3..726a27c1 100644
--- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
+++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs
@@ -451,30 +451,68 @@ long ShiftSeconds(DateTime? startAt, DateTime? stopAt, int startId, int stopId,
/// Aggregates pause minutes for a PlanRegistration.
///
/// When useOneMinuteIntervals is true, sums the (Pause*StoppedAt - Pause*StartedAt)
- /// DateTime deltas in seconds across all 5 pause slots that have BOTH stamps populated,
- /// then rounds to whole minutes. This preserves second/minute precision for pauses
- /// recorded by workers on UseOneMinuteIntervals-enabled sites (e.g. a 3-min pause
- /// reads as 3, not 0).
+ /// DateTime deltas in seconds across every populated pause stamp pair — the 5 main
+ /// slots AND all sub-slots used by the multi-pause workflow (Pause10..Pause19,
+ /// Pause100..Pause102 for shift 1; Pause20..Pause29, Pause200..Pause202 for shift 2;
+ /// Pause3/4/5 single slots for shifts 3/4/5). This mirrors the frontend admin edit
+ /// dialog's computeExactPauseMinutes (workday-entity-dialog.component.ts) so the
+ /// overview's "Samlet pause" and the per-row edit dialog agree byte-for-byte.
+ /// Total seconds are rounded down to whole minutes.
///
- /// When useOneMinuteIntervals is false, falls back to the legacy 5-minute-tick
- /// formula: for each Pause*Id > 0, contribute (Pause*Id * 5) - 5 minutes. (Pause*Id
- /// stores break in 5-minute ticks plus a +1 sentinel: Pause1Id = 1 means 0 min,
- /// Pause1Id = 4 means 15 min, etc.)
+ /// If a flag-on row has no stamp pairs populated, falls back to the legacy
+ /// 5-minute-tick formula on the 5 main slots only (sub-slots have no *Id field):
+ /// for each Pause{1..5}Id > 0, contribute (Pause{N}Id * 5) - 5 minutes. This
+ /// covers older flag-on rows written before stamp-pair pauses existed.
+ ///
+ /// When useOneMinuteIntervals is false, always uses the legacy 5-minute-tick
+ /// formula on the 5 main slots. (Pause*Id stores break in 5-minute ticks plus a
+ /// +1 sentinel: Pause1Id = 1 means 0 min, Pause1Id = 4 means 15 min, etc.)
///
public static int AggregatePauseMinutes(PlanRegistration pr, bool useOneMinuteIntervals)
{
if (useOneMinuteIntervals)
{
long totalSeconds = 0;
- totalSeconds += PauseSpanSeconds(pr.Pause1StartedAt, pr.Pause1StoppedAt);
- totalSeconds += PauseSpanSeconds(pr.Pause2StartedAt, pr.Pause2StoppedAt);
- totalSeconds += PauseSpanSeconds(pr.Pause3StartedAt, pr.Pause3StoppedAt);
- totalSeconds += PauseSpanSeconds(pr.Pause4StartedAt, pr.Pause4StoppedAt);
- totalSeconds += PauseSpanSeconds(pr.Pause5StartedAt, pr.Pause5StoppedAt);
+ var hasAnyStamp = false;
+
+ // Walk every pause stamp pair (31 total — mirrors the frontend's
+ // computeExactPauseMinutes via the shared EnumeratePauseStampPairs
+ // source of truth that GetPauseIntervals also consumes). Track
+ // whether ANY stamp was observed independently of the duration sum,
+ // so a populated-but-zero-duration pause (start == stop) doesn't
+ // wrongly trigger the legacy-tick fallback.
+ foreach (var (startedAt, stoppedAt) in EnumeratePauseStampPairs(pr))
+ {
+ if (startedAt.HasValue || stoppedAt.HasValue)
+ {
+ hasAnyStamp = true;
+ }
+ if (startedAt.HasValue && stoppedAt.HasValue && startedAt.Value < stoppedAt.Value)
+ {
+ totalSeconds += (long)(stoppedAt.Value - startedAt.Value).TotalSeconds;
+ }
+ }
+
+ if (!hasAnyStamp)
+ {
+ // Older flag-on rows without any stamp data may still carry
+ // legacy 5-minute-tick IDs in the 5 main pause slots.
+ return LegacyTickMinutesAcrossMainSlots(pr);
+ }
+
return (int)(totalSeconds / 60); // round down to whole minutes
}
- // Legacy 5-minute-tick path
+ // Flag-off branch: legacy 5-minute-tick path.
+ return LegacyTickMinutesAcrossMainSlots(pr);
+ }
+
+ ///
+ /// Sums the legacy 5-minute-tick pause IDs across the 5 main slots only.
+ /// Sub-slots have no *Id field, so they cannot contribute legacy ticks.
+ ///
+ private static int LegacyTickMinutesAcrossMainSlots(PlanRegistration pr)
+ {
var totalMinutes = 0;
if (pr.Pause1Id > 0) totalMinutes += (pr.Pause1Id * 5) - 5;
if (pr.Pause2Id > 0) totalMinutes += (pr.Pause2Id * 5) - 5;
@@ -2005,57 +2043,68 @@ public static async Task ReadBySiteAndDate(
}
}
+ ///
+ /// Single source of truth for every pause stamp pair on a PlanRegistration.
+ /// Enumerates Pause1-5, Pause10-19, Pause20-29, Pause100-102, Pause200-202
+ /// — 31 pairs total. Order mirrors the frontend's
+ /// workday-entity-dialog.component.ts:getPauseTimestampPairs so the
+ /// backend↔frontend mapping is easy to audit.
+ ///
+ /// Returns raw nullable pairs so callers can distinguish "no stamp"
+ /// from "stamp with zero/negative duration"; both
+ /// and
+ /// consume this enumerator.
+ ///
+ private static IEnumerable<(DateTime? StartedAt, DateTime? StoppedAt)> EnumeratePauseStampPairs(PlanRegistration pr)
+ {
+ // Main pause intervals 1-5
+ yield return (pr.Pause1StartedAt, pr.Pause1StoppedAt);
+ yield return (pr.Pause2StartedAt, pr.Pause2StoppedAt);
+ yield return (pr.Pause3StartedAt, pr.Pause3StoppedAt);
+ yield return (pr.Pause4StartedAt, pr.Pause4StoppedAt);
+ yield return (pr.Pause5StartedAt, pr.Pause5StoppedAt);
+
+ // Extended pause intervals 10-29
+ yield return (pr.Pause10StartedAt, pr.Pause10StoppedAt);
+ yield return (pr.Pause11StartedAt, pr.Pause11StoppedAt);
+ yield return (pr.Pause12StartedAt, pr.Pause12StoppedAt);
+ yield return (pr.Pause13StartedAt, pr.Pause13StoppedAt);
+ yield return (pr.Pause14StartedAt, pr.Pause14StoppedAt);
+ yield return (pr.Pause15StartedAt, pr.Pause15StoppedAt);
+ yield return (pr.Pause16StartedAt, pr.Pause16StoppedAt);
+ yield return (pr.Pause17StartedAt, pr.Pause17StoppedAt);
+ yield return (pr.Pause18StartedAt, pr.Pause18StoppedAt);
+ yield return (pr.Pause19StartedAt, pr.Pause19StoppedAt);
+ yield return (pr.Pause20StartedAt, pr.Pause20StoppedAt);
+ yield return (pr.Pause21StartedAt, pr.Pause21StoppedAt);
+ yield return (pr.Pause22StartedAt, pr.Pause22StoppedAt);
+ yield return (pr.Pause23StartedAt, pr.Pause23StoppedAt);
+ yield return (pr.Pause24StartedAt, pr.Pause24StoppedAt);
+ yield return (pr.Pause25StartedAt, pr.Pause25StoppedAt);
+ yield return (pr.Pause26StartedAt, pr.Pause26StoppedAt);
+ yield return (pr.Pause27StartedAt, pr.Pause27StoppedAt);
+ yield return (pr.Pause28StartedAt, pr.Pause28StoppedAt);
+ yield return (pr.Pause29StartedAt, pr.Pause29StoppedAt);
+
+ // Additional pause intervals 100-102
+ yield return (pr.Pause100StartedAt, pr.Pause100StoppedAt);
+ yield return (pr.Pause101StartedAt, pr.Pause101StoppedAt);
+ yield return (pr.Pause102StartedAt, pr.Pause102StoppedAt);
+
+ // Additional pause intervals 200-202
+ yield return (pr.Pause200StartedAt, pr.Pause200StoppedAt);
+ yield return (pr.Pause201StartedAt, pr.Pause201StoppedAt);
+ yield return (pr.Pause202StartedAt, pr.Pause202StoppedAt);
+ }
+
///
/// Extract pause intervals from PlanRegistration.
- /// Includes Pause1-5, Pause10-29, Pause100-102, Pause200-202.
- /// Returns intervals as (StartTime, EndTime) tuples.
- /// Ignores incomplete or invalid intervals (null or negative duration).
+ /// Consumes and filters out incomplete
+ /// or invalid intervals (null endpoints or non-positive duration).
///
private static IEnumerable<(DateTime Start, DateTime End)> GetPauseIntervals(PlanRegistration pr)
{
- var intervals = new (DateTime?, DateTime?)[]
- {
- // Main pause intervals 1-5
- (pr.Pause1StartedAt, pr.Pause1StoppedAt),
- (pr.Pause2StartedAt, pr.Pause2StoppedAt),
- (pr.Pause3StartedAt, pr.Pause3StoppedAt),
- (pr.Pause4StartedAt, pr.Pause4StoppedAt),
- (pr.Pause5StartedAt, pr.Pause5StoppedAt),
-
- // Extended pause intervals 10-29
- (pr.Pause10StartedAt, pr.Pause10StoppedAt),
- (pr.Pause11StartedAt, pr.Pause11StoppedAt),
- (pr.Pause12StartedAt, pr.Pause12StoppedAt),
- (pr.Pause13StartedAt, pr.Pause13StoppedAt),
- (pr.Pause14StartedAt, pr.Pause14StoppedAt),
- (pr.Pause15StartedAt, pr.Pause15StoppedAt),
- (pr.Pause16StartedAt, pr.Pause16StoppedAt),
- (pr.Pause17StartedAt, pr.Pause17StoppedAt),
- (pr.Pause18StartedAt, pr.Pause18StoppedAt),
- (pr.Pause19StartedAt, pr.Pause19StoppedAt),
- (pr.Pause20StartedAt, pr.Pause20StoppedAt),
- (pr.Pause21StartedAt, pr.Pause21StoppedAt),
- (pr.Pause22StartedAt, pr.Pause22StoppedAt),
- (pr.Pause23StartedAt, pr.Pause23StoppedAt),
- (pr.Pause24StartedAt, pr.Pause24StoppedAt),
- (pr.Pause25StartedAt, pr.Pause25StoppedAt),
- (pr.Pause26StartedAt, pr.Pause26StoppedAt),
- (pr.Pause27StartedAt, pr.Pause27StoppedAt),
- (pr.Pause28StartedAt, pr.Pause28StoppedAt),
- (pr.Pause29StartedAt, pr.Pause29StoppedAt),
-
- // Additional pause intervals 100-102
- (pr.Pause100StartedAt, pr.Pause100StoppedAt),
- (pr.Pause101StartedAt, pr.Pause101StoppedAt),
- (pr.Pause102StartedAt, pr.Pause102StoppedAt),
-
- // Additional pause intervals 200-202
- (pr.Pause200StartedAt, pr.Pause200StoppedAt),
- (pr.Pause201StartedAt, pr.Pause201StoppedAt),
- (pr.Pause202StartedAt, pr.Pause202StoppedAt)
- };
-
- foreach (var (start, end) in intervals)
+ foreach (var (start, end) in EnumeratePauseStampPairs(pr))
{
if (start.HasValue && end.HasValue && start.Value < end.Value)
{
diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts
new file mode 100644
index 00000000..7dd5df6a
--- /dev/null
+++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b1m/dashboard-pause-aggregate.spec.ts
@@ -0,0 +1,60 @@
+import { test, expect, Page } from '@playwright/test';
+import { LoginPage } from '../../../Page objects/Login.page';
+
+/**
+ * Plannings-table overview "Samlet pause" contract: when an AssignedSite has
+ * UseOneMinuteIntervals=true and a worker has a sub-slot pause (e.g.
+ * Pause10StartedAt/Pause10StoppedAt), the day cell must show the correct
+ * minute count instead of 00:00. Regression test for the bug fix introduced
+ * in commit 6ebc5fe6 + 43da9f2e (PR #1575) where the backend only iterated
+ * the 5 main pause slots and missed sub-slot stamps.
+ *
+ * Awaits DB fixture seeding for sub-slot pause stamps (the b1m CI shard's
+ * default seed flips UseOneMinuteIntervals on but does NOT write to Pause10*
+ * columns). Once that fixture lands, un-skip and the assertion below should
+ * pass.
+ *
+ * Until the fixture lands, the helper-level unit test
+ * PlanRegistrationHelperTests.AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot
+ * (eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs)
+ * plus the service-level call-site test
+ * PlanningServiceMultiShiftTests.Index_OneMinuteInterval_WithSubSlotPauseStamps_AggregatesCorrectly
+ * (same dir) cover the format-helper + aggregation contract on the
+ * Index → UpdatePlanRegistrationsInPeriod → AggregatePauseMinutes chain.
+ */
+
+async function waitForSpinner(page: Page) {
+ if (await page.locator('.overlay-spinner').count() > 0) {
+ await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 });
+ }
+}
+
+test.describe('Dashboard — Samlet pause aggregation for sub-slot stamps (b1m, flag-on)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('http://localhost:4200');
+ await new LoginPage(page).login();
+ });
+
+ test.skip('plannings-table cell renders correct Samlet pause for sub-slot pause when flag is on', async ({ page }) => {
+ // TODO(fixture): seed AssignedSite.UseOneMinuteIntervals = true on the
+ // worker referenced by #cell3_0 AND a PlanRegistration row with
+ // Pause10StartedAt = '2026-05-14T12:00:00Z'
+ // Pause10StoppedAt = '2026-05-14T12:03:00Z'
+ // on a date inside the dashboard's default visible range.
+ //
+ // Then the assertion shape is:
+ //
+ // await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click();
+ // await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click();
+ // await waitForSpinner(page);
+ //
+ // const totalBreak = page.locator('[id^="totalBreakTime"]').first();
+ // await expect(totalBreak).toContainText('00:03');
+ // // Negative guard — must not silently show 00:00 even when a sub-slot pause exists.
+ // await expect(totalBreak).not.toContainText('00:00');
+ //
+ // The unit-test layer covers the format-helper contract:
+ // PlanRegistrationHelperTests.AggregatePauseMinutes_OneMinuteInterval_3MinPauseInPause10SubSlot
+ // (eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanRegistrationHelperTests.cs).
+ });
+});