@oneuptime/common 11.3.26 → 11.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
- package/Models/DatabaseModels/IncidentMember.ts +1 -1
- package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
- package/Models/DatabaseModels/Index.ts +3 -0
- package/Models/DatabaseModels/MigrationFailure.ts +170 -0
- package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
- package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
- package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
- package/Server/API/AlertAPI.ts +1 -1
- package/Server/API/IncidentAPI.ts +2 -2
- package/Server/API/IncidentEpisodeAPI.ts +1 -1
- package/Server/API/LlmProviderAPI.ts +1 -0
- package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Infrastructure/PostgresDatabase.ts +11 -0
- package/Server/Services/AIService.ts +74 -2
- package/Server/Services/AnalyticsDatabaseService.ts +10 -1
- package/Server/Services/DatabaseService.ts +10 -1
- package/Server/Services/IncidentService.ts +1 -1
- package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
- package/Server/Services/IncomingCallPolicyService.ts +42 -1
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/MigrationFailureService.ts +9 -0
- package/Server/Services/MonitorProbeService.ts +33 -7
- package/Server/Services/MonitorService.ts +19 -7
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
- package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
- package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
- package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
- package/Server/Services/TeamMemberService.ts +6 -0
- package/Server/Services/UserNotificationRuleService.ts +13 -42
- package/Server/Services/UserOnCallLogService.ts +90 -0
- package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
- package/Server/Types/Database/QueryUtil.ts +57 -0
- package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +14 -2
- package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
- package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
- package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +183 -1
- package/Server/Utils/AI/Toolbox/Index.ts +4 -1
- package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
- package/Server/Utils/AI/Toolbox/Serializer.ts +42 -0
- package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
- package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
- package/Server/Utils/LLM/LLMService.ts +110 -11
- package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
- package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
- package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
- package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
- package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
- package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
- package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +78 -0
- package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +68 -0
- package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
- package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
- package/Tests/Types/Events/Recurring.test.ts +22 -9
- package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
- package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
- package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
- package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
- package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
- package/Types/Date.ts +200 -15
- package/Types/OnCallDutyPolicy/Layer.ts +790 -260
- package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
- package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
- package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
- package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
- package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
- package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
- package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
- package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
- package/build/dist/Server/API/AlertAPI.js +1 -1
- package/build/dist/Server/API/IncidentAPI.js +2 -2
- package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
- package/build/dist/Server/API/LlmProviderAPI.js +1 -0
- package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
- package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
- package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
- package/build/dist/Server/Services/AIService.js +53 -2
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +10 -1
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentService.js +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
- package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
- package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/MigrationFailureService.js +9 -0
- package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
- package/build/dist/Server/Services/MonitorProbeService.js +33 -7
- package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +12 -5
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
- package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
- package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +6 -0
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
- package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
- package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
- package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
- package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +13 -2
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
- package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
- package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
- package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +152 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +4 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
- package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +40 -0
- package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -1
- package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
- package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
- package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
- package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
- package/build/dist/Server/Utils/LLM/LLMService.js +81 -5
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
- package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
- package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
- package/build/dist/Types/Date.js +153 -15
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
- package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
- package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
- package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
- package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
- package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
- package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
- package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,12 +4,23 @@ import Recurring from "../Events/Recurring";
|
|
|
4
4
|
import Typeof from "../Typeof";
|
|
5
5
|
import RestrictionTimes, { RestrictionType, } from "./RestrictionTimes";
|
|
6
6
|
export default class LayerUtil {
|
|
7
|
+
constructor() {
|
|
8
|
+
/*
|
|
9
|
+
* The timezone of the layer currently being expanded. Set at the start of
|
|
10
|
+
* getEvents and read by the restriction-trimming helpers so wall-clock
|
|
11
|
+
* restriction windows resolve in the schedule's zone. undefined => local
|
|
12
|
+
* time (legacy behavior). getEvents runs synchronously, so this per-call
|
|
13
|
+
* field is not subject to interleaving.
|
|
14
|
+
*/
|
|
15
|
+
this.timezone = undefined;
|
|
16
|
+
}
|
|
7
17
|
getEvents(data, options) {
|
|
8
18
|
let events = [];
|
|
9
19
|
if (!this.isDataValid(data)) {
|
|
10
20
|
return [];
|
|
11
21
|
}
|
|
12
22
|
data = this.sanitizeData(data);
|
|
23
|
+
this.timezone = data.timezone;
|
|
13
24
|
let start = data.calendarStartDate;
|
|
14
25
|
const end = data.calendarEndDate;
|
|
15
26
|
// start time of the layer is after the start time of the calendar, so we need to update the start time of the calendar
|
|
@@ -28,7 +39,7 @@ export default class LayerUtil {
|
|
|
28
39
|
let currentEventStartTime = start;
|
|
29
40
|
// bring handoff time to the same day as the currentStartTime.
|
|
30
41
|
// before we do this, we need to update the user index.
|
|
31
|
-
|
|
42
|
+
const currentUserResolution = this.getCurrentUserIndexBasedOnHandoffTime({
|
|
32
43
|
rotation,
|
|
33
44
|
handOffTime,
|
|
34
45
|
currentUserIndex,
|
|
@@ -37,6 +48,16 @@ export default class LayerUtil {
|
|
|
37
48
|
currentEventStartTime,
|
|
38
49
|
restrictionTimes: data.restrictionTimes,
|
|
39
50
|
});
|
|
51
|
+
currentUserIndex = currentUserResolution.currentUserIndex;
|
|
52
|
+
/*
|
|
53
|
+
* True (un-clamped) start of the first rotation period we are about to
|
|
54
|
+
* expand. When the calendar window starts partway through a period (the
|
|
55
|
+
* live "who is on call now" path always starts its window at the current
|
|
56
|
+
* instant), currentEventStartTime is clamped to that instant. The advance
|
|
57
|
+
* guard in the loop below uses this to decide whether the first period
|
|
58
|
+
* consumed a rotation turn based on its FULL-span coverage (audit F2).
|
|
59
|
+
*/
|
|
60
|
+
const firstPeriodTrueStart = currentUserResolution.currentPeriodStart;
|
|
40
61
|
// update handoff time to the same day as current start time
|
|
41
62
|
handOffTime = this.moveHandsOffTimeAfterCurrentEventStartTime({
|
|
42
63
|
handOffTime,
|
|
@@ -57,15 +78,40 @@ export default class LayerUtil {
|
|
|
57
78
|
];
|
|
58
79
|
return events;
|
|
59
80
|
}
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
/*
|
|
82
|
+
* Bound the loop by the actual calendar window instead of a fixed count.
|
|
83
|
+
* Each iteration advances currentEventStartTime by at least one rotation
|
|
84
|
+
* period (fully-restricted periods produce no event but still advance the
|
|
85
|
+
* handoff), so at most ~windowUnits/periodUnits periods can fall inside
|
|
86
|
+
* [start, end]. A fixed cap of 100 silently truncated long windows for short
|
|
87
|
+
* rotations (audit F1) and could even return ZERO events — the schedule
|
|
88
|
+
* reporting nobody on-call and no next user — when "now" sat in a restriction
|
|
89
|
+
* gap longer than 100 periods (audit F8, e.g. an hourly rotation with a
|
|
90
|
+
* weekend-only restriction). Scale the cap to the window with a generous
|
|
91
|
+
* margin, keeping a hard ceiling to bound pathological inputs.
|
|
92
|
+
*/
|
|
93
|
+
const rawRotationCount = rotation.intervalCount.toNumber();
|
|
94
|
+
const periodUnitsForBound = Number.isFinite(rawRotationCount) && rawRotationCount >= 1
|
|
95
|
+
? Math.floor(rawRotationCount)
|
|
96
|
+
: 1;
|
|
97
|
+
const windowUnits = this.getUnitsBetweenDates(start, end, rotation.intervalType);
|
|
98
|
+
const maxLoopCount = Math.min(1000000, Math.max(100, Math.ceil(windowUnits / periodUnitsForBound) + 10));
|
|
62
99
|
let loopCount = 0;
|
|
100
|
+
/*
|
|
101
|
+
* The first loop iteration expands the rotation period that CONTAINS the
|
|
102
|
+
* window start; its currentEventStartTime may be clamped to the window
|
|
103
|
+
* start (now) rather than the true period start. Tracked so the rotation
|
|
104
|
+
* advance can be decided against the period's full span (audit F2).
|
|
105
|
+
*/
|
|
106
|
+
let isFirstPeriod = true;
|
|
63
107
|
while (!hasReachedTheEndOfTheCalendar) {
|
|
64
108
|
loopCount++;
|
|
65
109
|
if (loopCount > maxLoopCount) {
|
|
66
110
|
break;
|
|
67
111
|
}
|
|
68
112
|
currentEventEndTime = handOffTime;
|
|
113
|
+
// The rotation boundary that ends this period, before any clamp to `end`.
|
|
114
|
+
const periodBoundaryEnd = handOffTime;
|
|
69
115
|
// if current event start time and end time is the same then increase current event start time by 1 second.
|
|
70
116
|
if (OneUptimeDate.isSame(currentEventStartTime, currentEventEndTime)) {
|
|
71
117
|
currentEventStartTime = OneUptimeDate.addRemoveSeconds(currentEventEndTime, 1);
|
|
@@ -87,10 +133,15 @@ export default class LayerUtil {
|
|
|
87
133
|
eventEndTime: currentEventEndTime,
|
|
88
134
|
restrictionTimes: data.restrictionTimes,
|
|
89
135
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
136
|
+
/*
|
|
137
|
+
* push() instead of rebuilding the array with [...events, ...new] every
|
|
138
|
+
* iteration. The spread reallocated and copied the whole accumulated array
|
|
139
|
+
* each period — O(n^2) over the loop — which, combined with a window sized
|
|
140
|
+
* to a slow layer, made a fast (e.g. hourly) layer's expansion quadratic
|
|
141
|
+
* (audit H2). Each period contributes only a handful of segments, so the
|
|
142
|
+
* spread of the small per-period array as push args is safe.
|
|
143
|
+
*/
|
|
144
|
+
events.push(...this.getCalendarEventsFromStartAndEndDates(trimmedStartAndEndTimes, data.users, currentUserIndex));
|
|
94
145
|
if ((options === null || options === void 0 ? void 0 : options.getNumberOfEvents) !== undefined) {
|
|
95
146
|
if (events.length >= options.getNumberOfEvents) {
|
|
96
147
|
return events;
|
|
@@ -105,15 +156,41 @@ export default class LayerUtil {
|
|
|
105
156
|
rotation: data.rotation,
|
|
106
157
|
});
|
|
107
158
|
/*
|
|
108
|
-
* Only advance the rotation if
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* issue #2413.
|
|
159
|
+
* Only advance the rotation if this rotation period actually produced
|
|
160
|
+
* coverage. Otherwise the user "lost" their turn to a fully restricted
|
|
161
|
+
* window (e.g. a weekend with Mon-Fri restrictions), which would skip
|
|
162
|
+
* rotations and break ordering across the gap. See issue #2413.
|
|
113
163
|
*/
|
|
114
|
-
|
|
164
|
+
let periodProducedCoverage = trimmedStartAndEndTimes.length > 0;
|
|
165
|
+
/*
|
|
166
|
+
* First-period correction (audit F2): when the window starts partway
|
|
167
|
+
* through the current period AND begins after that period's restriction
|
|
168
|
+
* window has already closed (the live roster refresh resolving in a
|
|
169
|
+
* daily/weekend off-hours gap), the clamped [now, periodEnd] slice trims
|
|
170
|
+
* to nothing even though the period DID have coverage earlier. Deciding
|
|
171
|
+
* the advance on that empty clamped slice carried the current user into
|
|
172
|
+
* the next period, so every subsequent shift resolved one user off from
|
|
173
|
+
* the calendar/full expansion — paging/notifying the wrong "next" user.
|
|
174
|
+
* Re-evaluate the advance against the period's FULL span so a
|
|
175
|
+
* partially-elapsed period still consumes its rotation turn, while a
|
|
176
|
+
* genuinely fully-restricted period (full-span trim also empty) still
|
|
177
|
+
* correctly skips its turn and preserves the #2413 behavior.
|
|
178
|
+
*/
|
|
179
|
+
if (isFirstPeriod &&
|
|
180
|
+
!periodProducedCoverage &&
|
|
181
|
+
data.restrictionTimes &&
|
|
182
|
+
data.restrictionTimes.restictionType !== RestrictionType.None) {
|
|
183
|
+
const fullSpanTrim = this.trimStartAndEndTimesBasedOnRestrictionTimes({
|
|
184
|
+
eventStartTime: firstPeriodTrueStart,
|
|
185
|
+
eventEndTime: periodBoundaryEnd,
|
|
186
|
+
restrictionTimes: data.restrictionTimes,
|
|
187
|
+
});
|
|
188
|
+
periodProducedCoverage = fullSpanTrim.length > 0;
|
|
189
|
+
}
|
|
190
|
+
if (periodProducedCoverage) {
|
|
115
191
|
currentUserIndex = this.incrementUserIndex(currentUserIndex, data.users.length);
|
|
116
192
|
}
|
|
193
|
+
isFirstPeriod = false;
|
|
117
194
|
}
|
|
118
195
|
// increment ids of all the events and return them, to make sure they are unique
|
|
119
196
|
let id = 1;
|
|
@@ -169,105 +246,150 @@ export default class LayerUtil {
|
|
|
169
246
|
if (OneUptimeDate.isAfter(data.handOffTime, data.currentEventStartTime)) {
|
|
170
247
|
return data.handOffTime;
|
|
171
248
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
intervalBetweenStartTimeAndHandoffTime = rotationInterval;
|
|
231
|
-
}
|
|
232
|
-
else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
|
|
233
|
-
0) {
|
|
234
|
-
intervalBetweenStartTimeAndHandoffTime += rotationInterval;
|
|
235
|
-
}
|
|
236
|
-
// add intervalBetweenStartTimeAndHandoffTime to handoff time
|
|
237
|
-
handOffTime = OneUptimeDate.addRemoveMonths(handOffTime, intervalBetweenStartTimeAndHandoffTime);
|
|
238
|
-
if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
|
|
239
|
-
handOffTime = OneUptimeDate.addRemoveMonths(handOffTime, 1);
|
|
240
|
-
}
|
|
241
|
-
return handOffTime;
|
|
242
|
-
}
|
|
243
|
-
if (data.rotation.intervalType === EventInterval.Year) {
|
|
244
|
-
intervalBetweenStartTimeAndHandoffTime =
|
|
245
|
-
OneUptimeDate.getYearsBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
|
|
246
|
-
if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
|
|
247
|
-
intervalBetweenStartTimeAndHandoffTime = rotationInterval;
|
|
248
|
-
}
|
|
249
|
-
else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
|
|
250
|
-
0) {
|
|
251
|
-
intervalBetweenStartTimeAndHandoffTime += rotationInterval;
|
|
252
|
-
}
|
|
253
|
-
// add intervalBetweenStartTimeAndHandoffTime to handoff time
|
|
254
|
-
handOffTime = OneUptimeDate.addRemoveYears(handOffTime, intervalBetweenStartTimeAndHandoffTime);
|
|
255
|
-
if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
|
|
256
|
-
handOffTime = OneUptimeDate.addRemoveYears(handOffTime, 1);
|
|
257
|
-
}
|
|
258
|
-
return handOffTime;
|
|
249
|
+
const rawRotationInterval = data.rotation.intervalCount.toNumber();
|
|
250
|
+
/*
|
|
251
|
+
* Defensive clamp: an invalid interval count (0, NaN, negative) would make
|
|
252
|
+
* the alignment below produce an Invalid Date and spin the main getEvents
|
|
253
|
+
* loop. Treat any invalid value as a single unit.
|
|
254
|
+
*/
|
|
255
|
+
const rotationInterval = Number.isFinite(rawRotationInterval) && rawRotationInterval >= 1
|
|
256
|
+
? Math.floor(rawRotationInterval)
|
|
257
|
+
: 1;
|
|
258
|
+
const intervalType = data.rotation.intervalType;
|
|
259
|
+
/*
|
|
260
|
+
* Rotation boundaries are exactly handOffTime + k * rotationInterval units
|
|
261
|
+
* (k a non-negative integer). Return the SMALLEST such boundary that is
|
|
262
|
+
* strictly after currentEventStartTime.
|
|
263
|
+
*
|
|
264
|
+
* We start from the floor of the whole-period distance and step UP one full
|
|
265
|
+
* rotation period at a time until strictly after the target. This:
|
|
266
|
+
* - stays on the interval grid (multiples of rotationInterval), so
|
|
267
|
+
* intervalCount >= 2 rotations never drift onto an off-grid boundary
|
|
268
|
+
* (audit HIGH-1); and
|
|
269
|
+
* - never OVERSHOOTS by a whole period. The previous
|
|
270
|
+
* ceil(getUnitsInclusive / interval) * interval formula used an
|
|
271
|
+
* INCLUSIVE unit count, which overshot by a full period for positions in
|
|
272
|
+
* the last partial period before a boundary — and a DST offset shift
|
|
273
|
+
* could push the inclusive count across an even/odd threshold — yielding
|
|
274
|
+
* a first period that spanned two rotations and resolved the wrong
|
|
275
|
+
* current/next on-call user for intervalCount >= 2 rotations.
|
|
276
|
+
* addRotationUnits carries the timezone per interval type (wall-clock across
|
|
277
|
+
* DST for Day/Week/Month/Year; absolute for Hour), matching the main loop.
|
|
278
|
+
*/
|
|
279
|
+
const unitsBetween = this.getUnitsBetweenDates(data.handOffTime, data.currentEventStartTime, intervalType);
|
|
280
|
+
let periods = Math.floor(unitsBetween / rotationInterval);
|
|
281
|
+
if (!Number.isFinite(periods) || periods < 0) {
|
|
282
|
+
periods = 0;
|
|
283
|
+
}
|
|
284
|
+
/*
|
|
285
|
+
* Compute the boundary `periods` rotation periods after the anchor. For
|
|
286
|
+
* Month/Year this ITERATES one period at a time (see addRotationPeriods),
|
|
287
|
+
* because moment end-of-month-clamps a single multiplied add differently
|
|
288
|
+
* than stepping period-by-period — e.g. Jan-31 + 11 months multiplied =
|
|
289
|
+
* Dec-31, but eleven iterated +1-month steps = Dec-28. countElapsedRotation
|
|
290
|
+
* Periods and the main getEvents loop both walk the ITERATED grid, so a
|
|
291
|
+
* multiplied step here produced handoff boundaries off that grid and paged
|
|
292
|
+
* the wrong on-call user for Month/Year rotations anchored on day 29-31 (or
|
|
293
|
+
* Feb-29 for Year) — audit H1.
|
|
294
|
+
*/
|
|
295
|
+
let handOffTime = this.addRotationPeriods(data.handOffTime, periods, rotationInterval, intervalType);
|
|
296
|
+
let safety = 0;
|
|
297
|
+
while (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime) &&
|
|
298
|
+
safety < 1000000) {
|
|
299
|
+
periods++;
|
|
300
|
+
/*
|
|
301
|
+
* Step exactly ONE rotation period from the PREVIOUS boundary (not a fresh
|
|
302
|
+
* multiplied add from the anchor), so Month/Year stay on the same iterated,
|
|
303
|
+
* calendar-clamped grid as the initial jump above.
|
|
304
|
+
*/
|
|
305
|
+
handOffTime = this.addRotationUnits(handOffTime, rotationInterval, intervalType);
|
|
306
|
+
safety++;
|
|
259
307
|
}
|
|
260
308
|
return handOffTime;
|
|
261
309
|
}
|
|
310
|
+
/*
|
|
311
|
+
* Advance `anchor` by `numberOfPeriods` rotation periods (each period =
|
|
312
|
+
* rotationInterval units of intervalType). Hour/Day/Week have no calendar-
|
|
313
|
+
* length clamping, so a single multiplied add is exact and O(1). Month/Year
|
|
314
|
+
* DO clamp (moment: Jan-31 + 1mo = Feb-28, then Feb-28 + 1mo = Mar-28), so a
|
|
315
|
+
* multiplied add does NOT equal iterating; we step one period at a time to
|
|
316
|
+
* stay on the same grid the rest of the engine walks (audit H1). The period
|
|
317
|
+
* count for Month/Year is small even over decades, so iterating is cheap.
|
|
318
|
+
*/
|
|
319
|
+
addRotationPeriods(anchor, numberOfPeriods, rotationInterval, intervalType) {
|
|
320
|
+
if (numberOfPeriods <= 0) {
|
|
321
|
+
return anchor;
|
|
322
|
+
}
|
|
323
|
+
if (intervalType === EventInterval.Month ||
|
|
324
|
+
intervalType === EventInterval.Year) {
|
|
325
|
+
let result = anchor;
|
|
326
|
+
let safety = 0;
|
|
327
|
+
while (safety < numberOfPeriods && safety < 1000000) {
|
|
328
|
+
result = this.addRotationUnits(result, rotationInterval, intervalType);
|
|
329
|
+
safety++;
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
return this.addRotationUnits(anchor, numberOfPeriods * rotationInterval, intervalType);
|
|
334
|
+
}
|
|
262
335
|
getCurrentUserIndexBasedOnHandoffTime(data) {
|
|
336
|
+
/*
|
|
337
|
+
* Returns both the on-call user index for the rotation period that CONTAINS
|
|
338
|
+
* currentEventStartTime AND the true (un-clamped) start of that period.
|
|
339
|
+
* getEvents needs the true period start so it can decide, for the first
|
|
340
|
+
* (possibly clamped) period, whether that period consumed a rotation turn
|
|
341
|
+
* based on its FULL-span restriction coverage rather than the coverage in
|
|
342
|
+
* the clamped [now, periodEnd] slice (see the first-period advance guard in
|
|
343
|
+
* getEvents — audit F2).
|
|
344
|
+
*/
|
|
263
345
|
let currentUserIndex = data.currentUserIndex;
|
|
264
346
|
// if current event start time is before layer start, idx unchanged.
|
|
265
347
|
if (OneUptimeDate.isBefore(data.currentEventStartTime, data.startDateTimeOfLayer)) {
|
|
266
|
-
return
|
|
348
|
+
return {
|
|
349
|
+
currentUserIndex,
|
|
350
|
+
currentPeriodStart: data.currentEventStartTime,
|
|
351
|
+
};
|
|
267
352
|
}
|
|
268
353
|
// if handoff is after current start, no rotation has occurred yet — idx unchanged.
|
|
269
354
|
if (OneUptimeDate.isAfter(data.handOffTime, data.currentEventStartTime)) {
|
|
270
|
-
|
|
355
|
+
/*
|
|
356
|
+
* No handoff has happened yet, so we are still inside the very first
|
|
357
|
+
* rotation period, which starts at the layer start.
|
|
358
|
+
*/
|
|
359
|
+
return {
|
|
360
|
+
currentUserIndex,
|
|
361
|
+
currentPeriodStart: data.startDateTimeOfLayer,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/*
|
|
365
|
+
* Fast path: with no restriction, every rotation period produces exactly
|
|
366
|
+
* one event, so the current user index is simply the initial index plus the
|
|
367
|
+
* number of whole rotation periods elapsed since the first handoff. We
|
|
368
|
+
* compute this analytically in O(1) instead of simulating one iteration per
|
|
369
|
+
* period. The simulation below capped at 10000 iterations and returned the
|
|
370
|
+
* WRONG user for long-lived schedules (e.g. an hourly rotation older than
|
|
371
|
+
* ~14 months). Restricted layers still use the period-by-period simulation
|
|
372
|
+
* because fully-restricted periods must not advance the rotation.
|
|
373
|
+
*/
|
|
374
|
+
if (data.restrictionTimes &&
|
|
375
|
+
data.restrictionTimes.restictionType === RestrictionType.None &&
|
|
376
|
+
data.users.length > 0) {
|
|
377
|
+
const firstBoundary = this.moveHandsOffTimeAfterCurrentEventStartTime({
|
|
378
|
+
handOffTime: data.handOffTime,
|
|
379
|
+
currentEventStartTime: data.startDateTimeOfLayer,
|
|
380
|
+
rotation: data.rotation,
|
|
381
|
+
});
|
|
382
|
+
const periodsElapsed = this.countElapsedRotationPeriods(firstBoundary, data.currentEventStartTime, data.rotation);
|
|
383
|
+
const length = data.users.length;
|
|
384
|
+
/*
|
|
385
|
+
* Unrestricted layers never have coverage gaps, so getEvents never needs
|
|
386
|
+
* the full-span first-period fallback for them; currentPeriodStart is
|
|
387
|
+
* returned for interface symmetry only and is not read on this path.
|
|
388
|
+
*/
|
|
389
|
+
return {
|
|
390
|
+
currentUserIndex: (((currentUserIndex + periodsElapsed) % length) + length) % length,
|
|
391
|
+
currentPeriodStart: data.currentEventStartTime,
|
|
392
|
+
};
|
|
271
393
|
}
|
|
272
394
|
/*
|
|
273
395
|
* Simulate rotation periods from layer start up to currentEventStartTime,
|
|
@@ -283,12 +405,22 @@ export default class LayerUtil {
|
|
|
283
405
|
rotation: data.rotation,
|
|
284
406
|
});
|
|
285
407
|
/*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
408
|
+
* Bound the simulation by the actual number of rotation periods between the
|
|
409
|
+
* layer start and the target, so the cap is always large enough to REACH the
|
|
410
|
+
* target for any realistic schedule age. A fixed 10000 cap (~14 months of
|
|
411
|
+
* hourly rotation) stopped early for long-lived sub-daily restricted
|
|
412
|
+
* schedules and returned the index at iteration 10000 instead of the index at
|
|
413
|
+
* "now" — paging the wrong current user (audit F9). We keep a very high
|
|
414
|
+
* ceiling to still bound pathological inputs. Restricted periods must be
|
|
415
|
+
* simulated one at a time (they must not advance the rotation), so this stays
|
|
416
|
+
* O(elapsed periods); for realistic ages that is small.
|
|
290
417
|
*/
|
|
291
|
-
const
|
|
418
|
+
const rawSimCount = data.rotation.intervalCount.toNumber();
|
|
419
|
+
const simPeriodUnits = Number.isFinite(rawSimCount) && rawSimCount >= 1
|
|
420
|
+
? Math.floor(rawSimCount)
|
|
421
|
+
: 1;
|
|
422
|
+
const simUnitsBetween = this.getUnitsBetweenDates(data.startDateTimeOfLayer, data.currentEventStartTime, data.rotation.intervalType);
|
|
423
|
+
const maxIterations = Math.min(5000000, Math.max(10000, Math.ceil(simUnitsBetween / simPeriodUnits) + 10));
|
|
292
424
|
let iterations = 0;
|
|
293
425
|
while (OneUptimeDate.isBefore(simulatedTime, data.currentEventStartTime) &&
|
|
294
426
|
iterations < maxIterations) {
|
|
@@ -313,7 +445,110 @@ export default class LayerUtil {
|
|
|
313
445
|
rotation: data.rotation,
|
|
314
446
|
});
|
|
315
447
|
}
|
|
316
|
-
|
|
448
|
+
/*
|
|
449
|
+
* simulatedTime is now the true (un-clamped) start of the rotation period
|
|
450
|
+
* that contains data.currentEventStartTime — the loop advances it to the
|
|
451
|
+
* next period start each covered iteration and breaks once a period would
|
|
452
|
+
* extend past the target, so it holds the current period's real start.
|
|
453
|
+
*/
|
|
454
|
+
return { currentUserIndex, currentPeriodStart: simulatedTime };
|
|
455
|
+
}
|
|
456
|
+
/*
|
|
457
|
+
* Count the number of rotation boundaries that fall on-or-before `target`,
|
|
458
|
+
* starting from `firstBoundary` and stepping by one rotation period. This is
|
|
459
|
+
* the number of whole rotation periods elapsed, used for the O(1) unrestricted
|
|
460
|
+
* current-user computation. Uses calendar-aware unit stepping so it stays
|
|
461
|
+
* correct for Month/Year (variable length) and across DST for Day/Week.
|
|
462
|
+
*/
|
|
463
|
+
countElapsedRotationPeriods(firstBoundary, target, rotation) {
|
|
464
|
+
if (OneUptimeDate.isAfter(firstBoundary, target)) {
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
const intervalType = rotation.intervalType;
|
|
468
|
+
const rawCount = rotation.intervalCount.toNumber();
|
|
469
|
+
const periodUnits = Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1;
|
|
470
|
+
/*
|
|
471
|
+
* Month and Year have variable calendar length (moment clamps end-of-month:
|
|
472
|
+
* Jan 31 + 1mo = Feb 29, and Feb 29 + 1mo = Mar 29). Because of that,
|
|
473
|
+
* boundary_k computed as a SINGLE multiplied step (anchor + k*units) does
|
|
474
|
+
* NOT equal advancing one period at a time — which is exactly how the real
|
|
475
|
+
* rotation in getEvents (via moveHandsOffTimeAfterCurrentEventStartTime)
|
|
476
|
+
* steps. Using the multiplied form here under-counted elapsed periods by one
|
|
477
|
+
* at month-end anchors and paged the previous on-call user (audit F0). So we
|
|
478
|
+
* iterate one clamped period at a time for Month/Year. The boundary count
|
|
479
|
+
* stays small even over decades of monthly/yearly rotation, so this is cheap.
|
|
480
|
+
*/
|
|
481
|
+
if (intervalType === EventInterval.Month ||
|
|
482
|
+
intervalType === EventInterval.Year) {
|
|
483
|
+
let periods = 0;
|
|
484
|
+
let boundary = firstBoundary;
|
|
485
|
+
let safety = 0;
|
|
486
|
+
while (OneUptimeDate.isOnOrBefore(boundary, target) && safety < 100000) {
|
|
487
|
+
periods++;
|
|
488
|
+
// step from the PREVIOUS boundary, mirroring the main-loop rotation.
|
|
489
|
+
boundary = this.addRotationUnits(boundary, periodUnits, intervalType);
|
|
490
|
+
safety++;
|
|
491
|
+
}
|
|
492
|
+
return periods;
|
|
493
|
+
}
|
|
494
|
+
/*
|
|
495
|
+
* Hour/Day/Week have no calendar-length clamping, so the O(1) analytic count
|
|
496
|
+
* (a multiplied step) is exact and equals iterating.
|
|
497
|
+
*/
|
|
498
|
+
const unitsBetween = this.getUnitsBetweenDates(firstBoundary, target, intervalType);
|
|
499
|
+
let periods = Math.floor(unitsBetween / periodUnits);
|
|
500
|
+
if (periods < 0) {
|
|
501
|
+
periods = 0;
|
|
502
|
+
}
|
|
503
|
+
/*
|
|
504
|
+
* `periods` is a lower bound (unit diffs truncate toward zero). Advance
|
|
505
|
+
* until firstBoundary + periods*periodUnits is strictly after target; the
|
|
506
|
+
* resulting count equals the number of boundaries on-or-before target.
|
|
507
|
+
*/
|
|
508
|
+
let safety = 0;
|
|
509
|
+
while (OneUptimeDate.isOnOrBefore(this.addRotationUnits(firstBoundary, periods * periodUnits, intervalType), target) &&
|
|
510
|
+
safety < 100000) {
|
|
511
|
+
periods++;
|
|
512
|
+
safety++;
|
|
513
|
+
}
|
|
514
|
+
return periods;
|
|
515
|
+
}
|
|
516
|
+
addRotationUnits(date, units, intervalType) {
|
|
517
|
+
/*
|
|
518
|
+
* Day/Week/Month/Year preserve schedule wall-clock across DST (consistent
|
|
519
|
+
* with moveHandsOffTimeAfterCurrentEventStartTime); Hour is absolute.
|
|
520
|
+
*/
|
|
521
|
+
const tz = this.timezone;
|
|
522
|
+
switch (intervalType) {
|
|
523
|
+
case EventInterval.Hour:
|
|
524
|
+
return OneUptimeDate.addRemoveHours(date, units);
|
|
525
|
+
case EventInterval.Day:
|
|
526
|
+
return OneUptimeDate.addRemoveDays(date, units, tz);
|
|
527
|
+
case EventInterval.Week:
|
|
528
|
+
return OneUptimeDate.addRemoveWeeks(date, units, tz);
|
|
529
|
+
case EventInterval.Month:
|
|
530
|
+
return OneUptimeDate.addRemoveMonths(date, units, tz);
|
|
531
|
+
case EventInterval.Year:
|
|
532
|
+
return OneUptimeDate.addRemoveYears(date, units, tz);
|
|
533
|
+
default:
|
|
534
|
+
return OneUptimeDate.addRemoveDays(date, units, tz);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
getUnitsBetweenDates(from, to, intervalType) {
|
|
538
|
+
switch (intervalType) {
|
|
539
|
+
case EventInterval.Hour:
|
|
540
|
+
return OneUptimeDate.getHoursBetweenTwoDates(from, to);
|
|
541
|
+
case EventInterval.Day:
|
|
542
|
+
return OneUptimeDate.getDaysBetweenTwoDates(from, to);
|
|
543
|
+
case EventInterval.Week:
|
|
544
|
+
return OneUptimeDate.getWeeksBetweenTwoDates(from, to);
|
|
545
|
+
case EventInterval.Month:
|
|
546
|
+
return OneUptimeDate.getMonthsBetweenTwoDates(from, to);
|
|
547
|
+
case EventInterval.Year:
|
|
548
|
+
return OneUptimeDate.getYearsBetweenTwoDates(from, to);
|
|
549
|
+
default:
|
|
550
|
+
return OneUptimeDate.getDaysBetweenTwoDates(from, to);
|
|
551
|
+
}
|
|
317
552
|
}
|
|
318
553
|
trimStartAndEndTimesBasedOnRestrictionTimes(data) {
|
|
319
554
|
const restrictionTimes = data.restrictionTimes;
|
|
@@ -327,15 +562,23 @@ export default class LayerUtil {
|
|
|
327
562
|
}
|
|
328
563
|
if (restrictionTimes.restictionType === RestrictionType.Daily &&
|
|
329
564
|
restrictionTimes.dayRestrictionTimes) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
565
|
+
/*
|
|
566
|
+
* Move the restriction window to the event's day WITHOUT mutating the
|
|
567
|
+
* shared RestrictionTimes object. The previous code wrote the moved
|
|
568
|
+
* start/end back into restrictionTimes.dayRestrictionTimes, corrupting the
|
|
569
|
+
* caller's object across events/layers/calls and making resolution
|
|
570
|
+
* order-dependent (a hygiene defect flagged in the audit). keepTimeButMoveDay
|
|
571
|
+
* preserves the time-of-day regardless of the base day, so working on a
|
|
572
|
+
* local copy produces identical windows with no shared-state side effects.
|
|
573
|
+
*/
|
|
574
|
+
const movedDayRestriction = {
|
|
575
|
+
startTime: OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.startTime, data.eventStartTime, this.timezone),
|
|
576
|
+
endTime: OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.endTime, data.eventStartTime, this.timezone),
|
|
577
|
+
};
|
|
335
578
|
return this.getEventsByDailyRestriction({
|
|
336
579
|
eventStartTime: data.eventStartTime,
|
|
337
580
|
eventEndTime: data.eventEndTime,
|
|
338
|
-
restrictionStartAndEndTime:
|
|
581
|
+
restrictionStartAndEndTime: movedDayRestriction,
|
|
339
582
|
props: {
|
|
340
583
|
intervalType: EventInterval.Day,
|
|
341
584
|
},
|
|
@@ -373,7 +616,47 @@ export default class LayerUtil {
|
|
|
373
616
|
...trimmedStartAndEndTimesForRestriction,
|
|
374
617
|
];
|
|
375
618
|
}
|
|
376
|
-
|
|
619
|
+
/*
|
|
620
|
+
* Collapse overlapping/touching segments. The wrap-around split in
|
|
621
|
+
* getWeeklyRestrictionTimesForWeek emits a head segment (early-week tail)
|
|
622
|
+
* plus a main segment, and getEventsByDailyRestriction tiles each weekly
|
|
623
|
+
* across the event window. For a rotation event spanning more than one ISO
|
|
624
|
+
* week, week k's main segment already covers the Sunday->Monday that week
|
|
625
|
+
* (k+1)'s head segment re-covers, producing duplicate/overlapping events for
|
|
626
|
+
* the same user (audit F3). Merging contiguous coverage is always safe here
|
|
627
|
+
* because every segment belongs to the same layer/user.
|
|
628
|
+
*/
|
|
629
|
+
return this.mergeOverlappingStartAndEndTimes(trimmedStartAndEndTimes);
|
|
630
|
+
}
|
|
631
|
+
mergeOverlappingStartAndEndTimes(times) {
|
|
632
|
+
if (times.length <= 1) {
|
|
633
|
+
return times;
|
|
634
|
+
}
|
|
635
|
+
const sorted = [...times].sort((a, b) => {
|
|
636
|
+
if (OneUptimeDate.isBefore(a.startTime, b.startTime)) {
|
|
637
|
+
return -1;
|
|
638
|
+
}
|
|
639
|
+
if (OneUptimeDate.isAfter(a.startTime, b.startTime)) {
|
|
640
|
+
return 1;
|
|
641
|
+
}
|
|
642
|
+
return 0;
|
|
643
|
+
});
|
|
644
|
+
const merged = [];
|
|
645
|
+
for (const current of sorted) {
|
|
646
|
+
const last = merged[merged.length - 1];
|
|
647
|
+
// overlapping or directly touching the previous window -> extend it.
|
|
648
|
+
if (last && OneUptimeDate.isOnOrAfter(last.endTime, current.startTime)) {
|
|
649
|
+
if (OneUptimeDate.isAfter(current.endTime, last.endTime)) {
|
|
650
|
+
last.endTime = current.endTime;
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
merged.push({
|
|
655
|
+
startTime: current.startTime,
|
|
656
|
+
endTime: current.endTime,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return merged;
|
|
377
660
|
}
|
|
378
661
|
getWeeklyRestrictionTimesForWeek(data) {
|
|
379
662
|
const weeklyRestrictionTimes = data.restrictionTimes.weeklyRestrictionTimes;
|
|
@@ -384,8 +667,8 @@ export default class LayerUtil {
|
|
|
384
667
|
let startTime = weeklyRestriction.startTime;
|
|
385
668
|
let endTime = weeklyRestriction.endTime;
|
|
386
669
|
// move start and end times to the week of the event start time
|
|
387
|
-
startTime = OneUptimeDate.moveDateToTheDayOfWeek(startTime, eventStartTime, OneUptimeDate.getDayOfWeek(startTime));
|
|
388
|
-
endTime = OneUptimeDate.moveDateToTheDayOfWeek(endTime, eventStartTime, OneUptimeDate.getDayOfWeek(endTime));
|
|
670
|
+
startTime = OneUptimeDate.moveDateToTheDayOfWeek(startTime, eventStartTime, OneUptimeDate.getDayOfWeek(startTime, this.timezone), this.timezone);
|
|
671
|
+
endTime = OneUptimeDate.moveDateToTheDayOfWeek(endTime, eventStartTime, OneUptimeDate.getDayOfWeek(endTime, this.timezone), this.timezone);
|
|
389
672
|
// now we have true start and end times of the weekly restriction
|
|
390
673
|
// if start time is after end time, we need to add one week to the end time
|
|
391
674
|
if (OneUptimeDate.isAfter(startTime, endTime)) {
|
|
@@ -394,22 +677,68 @@ export default class LayerUtil {
|
|
|
394
677
|
* One for start of the week
|
|
395
678
|
* and the other for end of the week .
|
|
396
679
|
*/
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
680
|
+
/*
|
|
681
|
+
* Anchor the split to the START OF THE ISO WEEK that contains the event,
|
|
682
|
+
* NOT to data.eventStartTime. When resolution begins mid-week (the live
|
|
683
|
+
* "who is on call now" path always starts its window at the current
|
|
684
|
+
* instant), using eventStartTime made the head segment
|
|
685
|
+
* [eventStartTime, endTime] inverted (start > end) whenever "now" was
|
|
686
|
+
* already past the window's end-day. getEventsByDailyRestriction then
|
|
687
|
+
* mis-read that inverted segment as an overnight window and sprayed
|
|
688
|
+
* phantom all-day on-call coverage across every day of the week, paging
|
|
689
|
+
* the wrong user during hours the restriction excludes. Using the real
|
|
690
|
+
* week start keeps the head segment correctly ordered; the later
|
|
691
|
+
* intersection with the event window discards any portion that has
|
|
692
|
+
* already elapsed.
|
|
693
|
+
*/
|
|
694
|
+
const startOfWeek = OneUptimeDate.getStartOfTheWeek(data.eventStartTime, this.timezone);
|
|
695
|
+
/*
|
|
696
|
+
* Head segment: the early-week tail (week start -> endTime) of a weekend
|
|
697
|
+
* window that opened the PREVIOUS period. This is what covers an
|
|
698
|
+
* in-progress wrap-around window when resolution starts mid-weekend
|
|
699
|
+
* (e.g. resolving on the Sunday of a Fri 20:00 -> Mon 08:00 window).
|
|
700
|
+
*/
|
|
400
701
|
startAndEndTimesOfWeeklyRestrictions.push({
|
|
401
702
|
startTime: startOfWeek,
|
|
402
703
|
endTime: endTime,
|
|
403
704
|
});
|
|
705
|
+
/*
|
|
706
|
+
* Main segment: the contiguous window from startTime (this week) through
|
|
707
|
+
* endTime moved to the NEXT week. Because this is a wrap-around,
|
|
708
|
+
* startTime is later in the week than endTime, so endTime + 7 days is the
|
|
709
|
+
* window's true close (e.g. Fri 20:00 -> the following Mon 08:00).
|
|
710
|
+
* Expressing it as one forward window — rather than clipping to the end
|
|
711
|
+
* of THIS ISO week — lets getEventsByDailyRestriction tile it weekly
|
|
712
|
+
* across a multi-week rotation event without leaving the Sunday/Monday
|
|
713
|
+
* portion of the weekend uncovered.
|
|
714
|
+
*/
|
|
404
715
|
startAndEndTimesOfWeeklyRestrictions.push({
|
|
405
716
|
startTime: startTime,
|
|
406
|
-
|
|
717
|
+
/*
|
|
718
|
+
* Forward the schedule timezone so this +7-day step is a wall-clock
|
|
719
|
+
* week in the schedule's zone, consistent with every sibling
|
|
720
|
+
* day-step in the weekly tiling path (audit F8). Without it, when the
|
|
721
|
+
* server zone differs from the schedule zone across a DST transition,
|
|
722
|
+
* the wrap-around window's close drifted by the DST offset and that
|
|
723
|
+
* drift then propagated to every subsequent weekend of the expansion.
|
|
724
|
+
*/
|
|
725
|
+
endTime: OneUptimeDate.addRemoveDays(endTime, 7, this.timezone),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
/*
|
|
730
|
+
* Non-wrapping restriction: emit the single window. This is gated in an
|
|
731
|
+
* `else` because the wrap-around case above is already fully described
|
|
732
|
+
* by the two split segments; previously this raw push ran
|
|
733
|
+
* unconditionally, adding a third INVERTED (start > end) segment that
|
|
734
|
+
* getEventsByDailyRestriction then re-expanded into phantom nightly
|
|
735
|
+
* on-call windows on every day of the week.
|
|
736
|
+
*/
|
|
737
|
+
startAndEndTimesOfWeeklyRestrictions.push({
|
|
738
|
+
startTime,
|
|
739
|
+
endTime,
|
|
407
740
|
});
|
|
408
741
|
}
|
|
409
|
-
startAndEndTimesOfWeeklyRestrictions.push({
|
|
410
|
-
startTime,
|
|
411
|
-
endTime,
|
|
412
|
-
});
|
|
413
742
|
}
|
|
414
743
|
return startAndEndTimesOfWeeklyRestrictions;
|
|
415
744
|
}
|
|
@@ -436,19 +765,36 @@ export default class LayerUtil {
|
|
|
436
765
|
*/
|
|
437
766
|
if (OneUptimeDate.isBefore(restrictionEndTime, restrictionStartTime)) {
|
|
438
767
|
const results = [];
|
|
439
|
-
|
|
440
|
-
|
|
768
|
+
/*
|
|
769
|
+
* Iterate day-by-day within the event range. We start ONE day BEFORE the
|
|
770
|
+
* event's start day so the "morning" tail of the window that opened the
|
|
771
|
+
* previous night (e.g. a 22:00 -> 06:00 window covering 00:00 -> 06:00 on
|
|
772
|
+
* the event's own first day) is emitted. Previously the loop started at
|
|
773
|
+
* getStartOfDay(eventStart) and only ever tied the morning segment to the
|
|
774
|
+
* NEXT day, so a rotation event beginning at midnight lost its first-day
|
|
775
|
+
* morning coverage entirely, leaving a nightly gap where nobody was on
|
|
776
|
+
* call. The addIntersection clip to [eventStart, eventEnd] discards any
|
|
777
|
+
* segment of the extra leading day that falls outside the event.
|
|
778
|
+
*/
|
|
779
|
+
let currentDayStart = OneUptimeDate.addRemoveDays(OneUptimeDate.getStartOfDay(data.eventStartTime, this.timezone), -1, this.timezone);
|
|
441
780
|
const absoluteEventEnd = data.eventEndTime;
|
|
442
781
|
let safetyCounter = 0;
|
|
443
|
-
|
|
782
|
+
/*
|
|
783
|
+
* Scale the day-by-day safeguard to the actual event span. A fixed 62-day
|
|
784
|
+
* cap dropped every night past ~day 62 for rotation events longer than
|
|
785
|
+
* that (e.g. a quarterly/annual rotation with an overnight restriction),
|
|
786
|
+
* leaving those nights with no on-call coverage (audit F2). Bound to the
|
|
787
|
+
* event length plus margin, with a hard ceiling for pathological inputs.
|
|
788
|
+
*/
|
|
789
|
+
const maxDays = Math.min(4000, Math.max(62, OneUptimeDate.getDaysBetweenTwoDates(data.eventStartTime, data.eventEndTime) + 3));
|
|
444
790
|
while (OneUptimeDate.isOnOrBefore(currentDayStart, absoluteEventEnd) &&
|
|
445
791
|
safetyCounter < maxDays) {
|
|
446
792
|
safetyCounter++;
|
|
447
|
-
const segmentNightStart = OneUptimeDate.keepTimeButMoveDay(restrictionStartTime, currentDayStart);
|
|
448
|
-
const segmentNightEnd = OneUptimeDate.getEndOfDay(segmentNightStart);
|
|
449
|
-
const nextDayStart = OneUptimeDate.addRemoveDays(currentDayStart, 1);
|
|
450
|
-
const segmentMorningStart = OneUptimeDate.getStartOfDay(nextDayStart);
|
|
451
|
-
const segmentMorningEnd = OneUptimeDate.keepTimeButMoveDay(restrictionEndTime, nextDayStart);
|
|
793
|
+
const segmentNightStart = OneUptimeDate.keepTimeButMoveDay(restrictionStartTime, currentDayStart, this.timezone);
|
|
794
|
+
const segmentNightEnd = OneUptimeDate.getEndOfDay(segmentNightStart, this.timezone);
|
|
795
|
+
const nextDayStart = OneUptimeDate.addRemoveDays(currentDayStart, 1, this.timezone);
|
|
796
|
+
const segmentMorningStart = OneUptimeDate.getStartOfDay(nextDayStart, this.timezone);
|
|
797
|
+
const segmentMorningEnd = OneUptimeDate.keepTimeButMoveDay(restrictionEndTime, nextDayStart, this.timezone);
|
|
452
798
|
// helper to add intersection if it overlaps the event window
|
|
453
799
|
const addIntersection = (segStart, segEnd) => {
|
|
454
800
|
// normalize zero / invalid lengths
|
|
@@ -473,8 +819,14 @@ export default class LayerUtil {
|
|
|
473
819
|
const currentEndTime = data.eventEndTime;
|
|
474
820
|
const trimmedStartAndEndTimes = [];
|
|
475
821
|
let reachedTheEndOfTheCurrentEvent = false;
|
|
476
|
-
|
|
477
|
-
|
|
822
|
+
/*
|
|
823
|
+
* Scale the break clause to the event span. The loop advances one restriction
|
|
824
|
+
* period (1 day for a Daily restriction, 7 days for a Weekly one) per
|
|
825
|
+
* iteration, so a single rotation event longer than ~50 days had its later
|
|
826
|
+
* days silently dropped, leaving no on-call coverage (audit F2). Days-in-event
|
|
827
|
+
* is a safe upper bound for both the daily (+1/day) and weekly (+7/day) paths.
|
|
828
|
+
*/
|
|
829
|
+
const maxLoopCount = Math.min(4000, Math.max(50, OneUptimeDate.getDaysBetweenTwoDates(data.eventStartTime, data.eventEndTime) + 10));
|
|
478
830
|
let loopCount = 0;
|
|
479
831
|
while (!reachedTheEndOfTheCurrentEvent) {
|
|
480
832
|
loopCount++;
|
|
@@ -489,15 +841,37 @@ export default class LayerUtil {
|
|
|
489
841
|
if (OneUptimeDate.isOnOrAfter(restrictionStartTime, currentEndTime)) {
|
|
490
842
|
return trimmedStartAndEndTimes;
|
|
491
843
|
}
|
|
492
|
-
|
|
844
|
+
/*
|
|
845
|
+
* The event begins after THIS day's restriction window has already ended.
|
|
846
|
+
* Do NOT drop the whole event — a multi-day rotation event (e.g. a WEEKLY
|
|
847
|
+
* rotation whose handoff/start is at 20:00, with a 09:00-17:00 daily
|
|
848
|
+
* restriction) must still be covered on its subsequent days. Advance the
|
|
849
|
+
* restriction window to the next day/week and re-test instead of returning
|
|
850
|
+
* empty. Termination is preserved: after at most one advance the window's
|
|
851
|
+
* end moves past currentStartTime, and the "restrictionStart past
|
|
852
|
+
* currentEnd" guard above returns once the window moves past the event end
|
|
853
|
+
* (so a short event entirely after the window still yields no coverage).
|
|
854
|
+
*/
|
|
493
855
|
if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionEndTime)) {
|
|
494
|
-
|
|
856
|
+
restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
857
|
+
this.timezone);
|
|
858
|
+
restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
859
|
+
this.timezone);
|
|
860
|
+
continue;
|
|
495
861
|
}
|
|
496
862
|
// if the restriction end time is before the restriction start time, we need to add one day to the restriction end time
|
|
497
863
|
if (OneUptimeDate.isAfter(restrictionStartTime, restrictionEndTime)) {
|
|
498
864
|
restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7);
|
|
499
865
|
}
|
|
500
|
-
|
|
866
|
+
/*
|
|
867
|
+
* The four cases below are mutually exclusive for a given iteration and
|
|
868
|
+
* are expressed as an if / else-if chain. This matters because cases 2 and
|
|
869
|
+
* 4 MUTATE currentStartTime / restrictionStartTime / restrictionEndTime and
|
|
870
|
+
* then continue the loop; without else-if, a later case would re-evaluate
|
|
871
|
+
* against the freshly-mutated state within the same iteration and emit a
|
|
872
|
+
* duplicate (or overlapping) window.
|
|
873
|
+
*/
|
|
874
|
+
// 1 - the event falls entirely within the restriction window: emit it and finish.
|
|
501
875
|
if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
|
|
502
876
|
OneUptimeDate.isOnOrAfter(restrictionEndTime, currentEndTime)) {
|
|
503
877
|
trimmedStartAndEndTimes.push({
|
|
@@ -506,20 +880,33 @@ export default class LayerUtil {
|
|
|
506
880
|
});
|
|
507
881
|
reachedTheEndOfTheCurrentEvent = true;
|
|
508
882
|
}
|
|
883
|
+
else if (
|
|
509
884
|
/*
|
|
510
|
-
* 2 - Start Restriction:
|
|
511
|
-
*
|
|
885
|
+
* 2 - Start Restriction: the event starts inside the restriction window
|
|
886
|
+
* but extends past its end. Emit [currentStart, restrictionEnd], then
|
|
887
|
+
* ADVANCE to the next restriction day/week and continue, so every
|
|
888
|
+
* remaining day of a multi-day rotation event is emitted. Previously this
|
|
889
|
+
* terminated the loop after the first day, dropping on-call coverage for
|
|
890
|
+
* every subsequent day of the rotation period (e.g. a weekly rotation
|
|
891
|
+
* with a 09:00-17:00 daily restriction and a handoff at/after 09:00
|
|
892
|
+
* covered only day 1). This now mirrors case 4's advance-and-continue.
|
|
893
|
+
* Strict isAfter on the end keeps this exclusive from case 1.
|
|
512
894
|
*/
|
|
513
|
-
|
|
895
|
+
OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
|
|
514
896
|
OneUptimeDate.isAfter(currentEndTime, restrictionEndTime)) {
|
|
515
897
|
trimmedStartAndEndTimes.push({
|
|
516
898
|
startTime: currentStartTime,
|
|
517
899
|
endTime: restrictionEndTime,
|
|
518
900
|
});
|
|
519
|
-
|
|
901
|
+
currentStartTime = OneUptimeDate.addRemoveSeconds(restrictionEndTime, 1);
|
|
902
|
+
restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
903
|
+
this.timezone);
|
|
904
|
+
restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
905
|
+
this.timezone);
|
|
520
906
|
}
|
|
521
|
-
|
|
522
|
-
|
|
907
|
+
else if (
|
|
908
|
+
// 3 - End Restriction - the event starts before the window and ends inside it.
|
|
909
|
+
OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
|
|
523
910
|
OneUptimeDate.isBefore(currentEndTime, restrictionEndTime) &&
|
|
524
911
|
OneUptimeDate.isAfter(currentEndTime, restrictionStartTime)) {
|
|
525
912
|
trimmedStartAndEndTimes.push({
|
|
@@ -528,8 +915,9 @@ export default class LayerUtil {
|
|
|
528
915
|
});
|
|
529
916
|
reachedTheEndOfTheCurrentEvent = true;
|
|
530
917
|
}
|
|
531
|
-
|
|
532
|
-
|
|
918
|
+
else if (
|
|
919
|
+
// 4 - the event spans the whole window: emit it, advance a day/week, continue.
|
|
920
|
+
OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
|
|
533
921
|
OneUptimeDate.isOnOrAfter(currentEndTime, restrictionEndTime)) {
|
|
534
922
|
trimmedStartAndEndTimes.push({
|
|
535
923
|
startTime: restrictionStartTime,
|
|
@@ -537,8 +925,10 @@ export default class LayerUtil {
|
|
|
537
925
|
});
|
|
538
926
|
currentStartTime = OneUptimeDate.addRemoveSeconds(restrictionEndTime, 1);
|
|
539
927
|
// add day to restriction start and end times.
|
|
540
|
-
restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7
|
|
541
|
-
|
|
928
|
+
restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
929
|
+
this.timezone);
|
|
930
|
+
restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
|
|
931
|
+
this.timezone);
|
|
542
932
|
}
|
|
543
933
|
}
|
|
544
934
|
return trimmedStartAndEndTimes;
|
|
@@ -577,15 +967,25 @@ export default class LayerUtil {
|
|
|
577
967
|
const events = [];
|
|
578
968
|
let layerPriority = 1;
|
|
579
969
|
for (const layer of data.layers) {
|
|
970
|
+
/*
|
|
971
|
+
* Do NOT forward getNumberOfEvents to the per-layer expansion. Capping
|
|
972
|
+
* each layer to N events before the priority merge can drop a lower-
|
|
973
|
+
* priority (fallback) layer's post-block coverage: if a higher-priority
|
|
974
|
+
* layer's restricted block swallows the fallback's first N events, the
|
|
975
|
+
* fallback's (N+1)-th event — the true "next" on-call after the block —
|
|
976
|
+
* is never generated, corrupting the merged "next" roster. The cap is
|
|
977
|
+
* applied only once, after the merge, below.
|
|
978
|
+
*/
|
|
580
979
|
const layerEvents = this.getEvents({
|
|
581
980
|
users: layer.users,
|
|
582
981
|
startDateTimeOfLayer: layer.startDateTimeOfLayer,
|
|
583
982
|
restrictionTimes: layer.restrictionTimes,
|
|
584
983
|
handOffTime: layer.handOffTime,
|
|
585
984
|
rotation: layer.rotation,
|
|
985
|
+
timezone: layer.timezone,
|
|
586
986
|
calendarStartDate: data.calendarStartDate,
|
|
587
987
|
calendarEndDate: data.calendarEndDate,
|
|
588
|
-
}
|
|
988
|
+
});
|
|
589
989
|
// add priority to each event
|
|
590
990
|
for (const layerEvent of layerEvents) {
|
|
591
991
|
const priorityEvent = Object.assign(Object.assign({}, layerEvent), { priority: layerPriority });
|
|
@@ -654,10 +1054,32 @@ export default class LayerUtil {
|
|
|
654
1054
|
* end time of the final event will be the start time of the current event - 1 second
|
|
655
1055
|
*/
|
|
656
1056
|
const tempFinalEventEnd = finalEvent.end;
|
|
1057
|
+
/*
|
|
1058
|
+
* Reconstruct the trailing tail FIRST, before the front-collapse
|
|
1059
|
+
* removal below. If the lower-priority (fallback) event originally
|
|
1060
|
+
* extended past the higher-priority event, the portion AFTER the
|
|
1061
|
+
* higher-priority window must survive as its own segment — even when
|
|
1062
|
+
* the FRONT of the final event collapses to zero/negative length
|
|
1063
|
+
* (which happens when the higher-priority event starts at or before
|
|
1064
|
+
* the final event's start, e.g. two back-to-back higher-priority
|
|
1065
|
+
* rotation windows over a 24/7 fallback layer). Previously this block
|
|
1066
|
+
* ran only AFTER the collapse checks, whose `continue` skipped it,
|
|
1067
|
+
* silently deleting the fallback layer's coverage after the higher-
|
|
1068
|
+
* priority window and leaving on-call gaps where nobody is paged.
|
|
1069
|
+
*/
|
|
1070
|
+
if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
|
|
1071
|
+
// add the trailing segment of the lower-priority event
|
|
1072
|
+
const trimmedEvent = Object.assign(Object.assign({}, finalEvent), { priority: finalEvent.priority, start: OneUptimeDate.addRemoveSeconds(event.end, 1), end: tempFinalEventEnd });
|
|
1073
|
+
// only keep it if it has positive length
|
|
1074
|
+
if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
|
|
1075
|
+
finalEvents.push(trimmedEvent);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
657
1078
|
finalEvent.end = OneUptimeDate.addRemoveSeconds(event.start, -1);
|
|
658
1079
|
/*
|
|
659
1080
|
* check if the final event end time is before the start time of the current event
|
|
660
1081
|
* if it is, we need to remove the final event from the final events array
|
|
1082
|
+
* (the trailing tail, if any, was already preserved above)
|
|
661
1083
|
*/
|
|
662
1084
|
if (OneUptimeDate.isBefore(finalEvent.end, finalEvent.start)) {
|
|
663
1085
|
finalEvents.splice(i, 1);
|
|
@@ -670,22 +1092,19 @@ export default class LayerUtil {
|
|
|
670
1092
|
i--; // Adjust index after removal
|
|
671
1093
|
continue;
|
|
672
1094
|
}
|
|
673
|
-
// final event was originally ending after the current event, so we need to add the trimmed event to the final events array
|
|
674
|
-
if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
|
|
675
|
-
// add the trimmed event to the final events array
|
|
676
|
-
const trimmedEvent = Object.assign(Object.assign({}, finalEvent), { priority: finalEvent.priority, start: OneUptimeDate.addRemoveSeconds(event.end, 1), end: tempFinalEventEnd });
|
|
677
|
-
// check if the event end time is before the start time of the trimmed event
|
|
678
|
-
if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
|
|
679
|
-
finalEvents.push(trimmedEvent);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
1095
|
}
|
|
683
1096
|
else {
|
|
684
1097
|
/*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
1098
|
+
* Trim the current (lower-priority) event: push its start past this
|
|
1099
|
+
* higher-priority window. Use getGreaterDate (a monotonic max)
|
|
1100
|
+
* instead of a bare assignment so the result does NOT depend on the
|
|
1101
|
+
* order finalEvents are visited. That makes it safe to hoist the
|
|
1102
|
+
* per-iteration finalEvents.sort() out of the loop (audit H2): with
|
|
1103
|
+
* the old in-loop ascending sort, successive overlaps already had
|
|
1104
|
+
* monotonically increasing ends, so max equals the old assignment and
|
|
1105
|
+
* the output is unchanged.
|
|
687
1106
|
*/
|
|
688
|
-
event.start = OneUptimeDate.addRemoveSeconds(finalEvent.end, 1);
|
|
1107
|
+
event.start = OneUptimeDate.getGreaterDate(event.start, OneUptimeDate.addRemoveSeconds(finalEvent.end, 1));
|
|
689
1108
|
}
|
|
690
1109
|
}
|
|
691
1110
|
}
|
|
@@ -693,16 +1112,14 @@ export default class LayerUtil {
|
|
|
693
1112
|
if (OneUptimeDate.isAfter(event.end, event.start)) {
|
|
694
1113
|
finalEvents.push(event);
|
|
695
1114
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
return 0;
|
|
705
|
-
});
|
|
1115
|
+
/*
|
|
1116
|
+
* The finalEvents.sort() that used to run HERE — inside the per-event
|
|
1117
|
+
* loop — made the merge O(n^2 log n). It is hoisted to a single sort after
|
|
1118
|
+
* the loop (below). Correctness is preserved because overlap detection and
|
|
1119
|
+
* trimming do not depend on finalEvents being sorted (the current-event
|
|
1120
|
+
* trim above now uses a monotonic max), so sorting once at the end yields
|
|
1121
|
+
* the same result. Audit H2.
|
|
1122
|
+
*/
|
|
706
1123
|
// if an event starts and end at the same time, we need to remove it
|
|
707
1124
|
for (let index = 0; index < finalEvents.length; index++) {
|
|
708
1125
|
const finalEvent = finalEvents[index];
|
|
@@ -721,6 +1138,20 @@ export default class LayerUtil {
|
|
|
721
1138
|
}
|
|
722
1139
|
}
|
|
723
1140
|
}
|
|
1141
|
+
/*
|
|
1142
|
+
* Single final sort by start time (hoisted out of the per-event loop above,
|
|
1143
|
+
* audit H2). Downstream consumers (getEvents id assignment, the schedule
|
|
1144
|
+
* service's current/next selection) expect events in start order.
|
|
1145
|
+
*/
|
|
1146
|
+
finalEvents.sort((a, b) => {
|
|
1147
|
+
if (OneUptimeDate.isBefore(a.start, b.start)) {
|
|
1148
|
+
return -1;
|
|
1149
|
+
}
|
|
1150
|
+
if (OneUptimeDate.isAfter(a.start, b.start)) {
|
|
1151
|
+
return 1;
|
|
1152
|
+
}
|
|
1153
|
+
return 0;
|
|
1154
|
+
});
|
|
724
1155
|
// convert PriorityCalendarEvents to CalendarEvents
|
|
725
1156
|
const calendarEvents = [];
|
|
726
1157
|
let id = 1;
|