Overview

  • This tutorial shows the visual UI elements you can use to display a field service route schedules for multiple teams.

  • The Scheduler UI component displays each team in a separate row.

  • The horizontal axis displays the specified timeline (one month in this case).

  • Multiple field service tasks can be joined into a single route using links.

  • The ASP.NET Core project includes a trial version of DayPilot Pro for JavaScript (see License below).

For an introduction to using the Scheduler in an ASP.NET Core application, please see the ASP.NET Core Scheduler tutorial. It explains the basic setup and configuration of the Scheduler UI component, as well as using a .NET backend with Entity Framework and SQL Server database.

License

Licensed for testing and evaluation purposes. Please see the license agreement included in the sample project. You can use the source code of the tutorial if you are a licensed user of DayPilot Pro for JavaScript. Buy a license.

Displaying Field Service Teams and Tasks

ASP.NET Core Field Service Schedule - Displaying Teams and Tasks

To display field service teams in the Scheduler, we need to load team data from the server and update the Scheduler's resources.

The Scheduler displays tasks for each team in a separate row. This will provide a quick overview of the assigned tasks, available teams, and overall resource utilization.

To load the teams from the database, we implement the loadResources() function as follows:

async loadResources() {

    const { data: resources } = await DayPilot.Http.get("/api/Teams");
    scheduler.update({
        resources: resources
    });
},

The server-side /api/Teams endpoint is implemented in TeamsController:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Project.Models;

namespace Project.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TeamsController : ControllerBase
    {
        private readonly FieldServiceDbContext _context;

        public TeamsController(FieldServiceDbContext context)
        {
            _context = context;
        }

        // GET: api/Teams
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Team>>> GetTeams()
        {
            return await _context.Teams.ToListAsync();
        }

    }
}

The tasks and routes assigned to each team as stored as tasks, connected by links that represent the travel time between different field service locations.

We load the data in the loadTasks() function using two separate API calls that are executed in parallel. As soon as both responses are received, the function updates the Scheduler and displays the tasks and routes.

async loadTasks() {
    const start = scheduler.visibleStart();
    const end = scheduler.visibleEnd();

    const [tasksResponse, linksResponse] = await Promise.all([
        DayPilot.Http.get(`/api/Tasks?start=${start}&end=${end}`),
        DayPilot.Http.get(`/api/Links?start=${start}&end=${end}`)
    ]);

    scheduler.update({
        events: tasksResponse.data,
        links: linksResponse.data
    });

},

The /api/Tasks endpoint is implemented using a standard API Controller and Entity Framework. Note that we only load the data for the visible date range.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Project.Models;

namespace Project.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TasksController : ControllerBase
    {
        private readonly FieldServiceDbContext _context;

        public TasksController(FieldServiceDbContext context)
        {
            _context = context;
        }

        // GET: api/Tasks
        [HttpGet]
        public async Task<ActionResult<IEnumerable<ServiceTask>>> GetServiceTasks([FromQuery] DateTime start, [FromQuery] DateTime end)
        {
            return await _context.ServiceTasks.Where(e => !((e.End <= start) || (e.Start >= end))).ToListAsync();
        }
        
    }
}

The /api/Links endpoint uses a similar approach. It looks for tasks that fall between the start and end dates and loads the related links.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Project.Models;

namespace Project.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class LinksController : ControllerBase
    {
        private readonly FieldServiceDbContext _context;

        public LinksController(FieldServiceDbContext context)
        {
            _context = context;
        }

        // GET: api/Links
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Link>>> GetLinks([FromQuery] DateTime start, [FromQuery] DateTime end)
        {
            // Adjust the end date to include the entire day if needed
            end = end.Date.AddDays(1).AddTicks(-1);

            var links = await _context.Links
                .Where(link =>
                    _context.ServiceTasks.Any(task =>
                        (task.Id == link.From || task.Id == link.To) &&
                        task.Start < end && task.End > start))
                .ToListAsync();

            return Ok(links);
        }
    }   
}

Scheduling a Field Service Task using Drag and Drop

Scheduling a Field Service Task Using Drag-and-Drop in ASP.NET Core

The users will be able to schedule field service tasks using drag and drop. The task creation feature is enabled by default in the JavaScript Scheduler component.

In this step, we will configure the feature to ask for task details, enforce rules (such as minimum travel time between tasks), and store the new task in the database using an API call.

The core of the task creation implementation is the onTimeRangeSelected event handler, which is fired by the Scheduler when the user completes the drag-and-drop selection.

onTimeRangeSelected: async args => {
    const data = {
        start: args.start,
        end: args.end,
        resource: args.resource,
        text: "Task"
    };
    const modal = await DayPilot.Modal.form(app.eventForm, data);
    scheduler.clearSelection();
    if (modal.canceled) {
        return;
    }
    
    const e = modal.result;
    
    const {data: taskResult} = await DayPilot.Http.post("/api/Tasks", e);
    e.id = taskResult.id;    
    
    scheduler.events.add(e);
},

Our event handler opens a modal dialog using DayPilot.Modal.form(), which displays a form defined in app.eventForm:

const app = {
    eventForm: [
        {name: "Name", id: "text"},
    ],
};

This is a simple form with just a single field (name). You can create a custom form with additional fields using the online Modal Dialog Builder.

When the user confirms the form data, the event handler creates a new task in the database using a POST HTTP request to /api/Tasks. As soon as the request successfuly completes, we add the new task to the Scheduler UI using the events.add() method.

Now we will add custom rules for creation of a new task. This can be done using the onTimeRangeSelecting event handler, which is fired during drag and drop selecting (whenever the selection changes). This is a good place for checking business rules and providing immediate feedback to the user.

Our onTimeRangeSelecting implementation checks the distance from existing tasks, prevent task overlap and ensuring a minimum travel time between tasks in a route.

onTimeRangeSelecting: args => {
    const row = scheduler.rows.find(args.resource);
    
    // check distance from existing events
    const overlappingEvents = row.events.forRange(args.start.addMinutes(-app.minimumTravelMinutes), args.end.addMinutes(app.minimumTravelMinutes));
    if (overlappingEvents.length > 0) {
        args.allowed = false;
    }
},

Planning the Next Stop on a Route

ASP.NET Core Field Service Scheduling - Planning the Next Stop on a Route

To allow a quick scheduling of the next stop on a route, we will add a chaining icon to the end of the last task. Dragging the icon will create a new task that will be linked to the previous task automatically.

1. First, we need to add the icon to the tasks.

We add the icon using the onBeforeEventRender event handler that lets us customize the task appearance.

onBeforeEventRender: args => {
    args.data.fontColor = "#ffffff";

    const linksFrom = scheduler.links.findAllByFromTo(args.data.id, null);
    if (linksFrom.length === 0) {
        args.data.areas = [
            { 
                right: -6, 
                top: "calc(50% - 7px)", 
                width: 14, 
                height: 14, 
                style: "cursor: move; border-radius: 50%; border: 1px solid #ffffff;", 
                backColor: "#f37021", 
                fontColor: "#ffffff",  
                symbol: "/icons/daypilot.svg#minichevron-right-2", 
                action: "Move", 
                data: "next" 
            }
        ];
    }
},

The chaining icon is added to events only if the task is not already linked to a subsequent task.

  1. We define an active area that is located on the right side of the task.

  2. The area displays a chevron pointing to the right using an SVG symbol.

  3. The action: "Move" triggers event moving when the icon is dragged.

  4. The data: "next" attribute is used to identify the source area during event handling.

By default, the event content is not allowed to overlap the event box. The chaining icon is designed to partially overlap the event end so we need to override the built-in CSS theme:

.scheduler_default_event {
    overflow: visible !important;
}

Now we can add the event handlers that implement the new task logic.

2. The onEventMoving event handler is fired during dragging. In the event handler, we detect the source active area (using args.areaData) and apply the following rules:

  • Ensure the new task is scheduled for the same team (args.resource = args.e.resource()).

  • The duration of the new event is 30 minutes.

  • The event has to be placed after the source task.

  • The minimum travel time (distance from the source task) is 30 minutes (app.minimumTravelMinutes).

  • It must not overlap with any other task that is already scheduled (it must be placed before).

We also display a shadow link connecting the target task with the source task (args.links).

onEventMoving: args => {
    if (args.areaData === "next") {
        // Ensure the task stays on the same resource (team)
        if (args.e.resource() !== args.resource) {
            args.resource = args.e.resource();
        }

        // Set default duration for the new task
        const defaultDuration = 30; // minutes
        args.start = args.end.addMinutes(-defaultDuration);
        const minStart = args.e.end().addMinutes(app.minimumTravelMinutes);
        if (minStart > args.start) {
            args.start = minStart;
            args.end = args.start.addMinutes(defaultDuration);
        }

        // Check for conflicts with the next event
        const row = scheduler.rows.find(args.resource);
        const nextEvent = app.findNextEvent(row, args.e.end());
        if (nextEvent) {
            const maxEnd = nextEvent.start().addMinutes(-app.minimumTravelMinutes);
            if (args.end > maxEnd) {
                args.end = maxEnd;
                args.start = args.end.addMinutes(-defaultDuration);
            }
        }

        // Prevent invalid positioning
        if (args.start < minStart) {
            args.start = minStart;
            args.end = args.start.addMinutes(defaultDuration);
            args.allowed = false;
        } else {
            // Display a temporary link during dragging
            args.links = [{
                from: args.e,
                color: "#666666"
            }];
        }
    }
},

As soon as the dragging is complete (on drop), the Scheduler fires the onEventMove event handler. You can use it to process the drop event and store the new task to the database.

onEventMove: async args => {
    if (args.areaData === "next") {
        args.preventDefault();
        
        const e = {
            //id: DayPilot.guid(),
            start: args.newStart,
            end: args.newEnd,
            text: "New task",
            resource: args.newResource,
        };
        
        const {data: result} = await DayPilot.Http.post("/api/Tasks", e);
        e.id = result.id;
        
        scheduler.events.add(e);
        
        const newLink = {
            from: args.e.data.id,
            to: e.id,
            text: "Travel",
            textAlignment: "center",
        };
        const {data: link} = await DayPilot.Http.post("/api/Links", newLink);
        scheduler.links.add(link);
        scheduler.events.update(args.e.data);
    }
},

Our event handler creates a new task using a POST HTTP request to /api/Tasks and connects the task to the source by creating a new link using a POST request to /api/Links.

Deleting a Field Service Task

Deleting a Field Service Task in ASP.NET Core

The user interface will include a context menu available for each field service task. This context menu will include a "Delete" option, allowing users to remove a selected task from the schedule easily.

  • By right-clicking on a task, users can access a context menu with two options: "Edit…" and "Delete."

  • When a task is deleted, the application ensures that the continuity of the route is preserved by updating the links between tasks.

  • The delete logic handles the removal and re-creation of links (representing travel time) between tasks to reflect the changes.

To define the context menu for tasks, use the contextMenu property of the Scheduler config:

contextMenu: new DayPilot.Menu({
    items: [
        {
            text: "Edit...",
            onClick: async args => {
                await app.editTask(args.source);
            }
        },
        {
            text: "-"
        },
        {
            text: "Delete", 
            onClick: async args => {
                // Delete logic will be implemented here
            }
        }
    ]
}),

When the "Delete" option is selected, we need to perform several steps to ensure the Scheduler updates correctly:

1. Find the link from the previous task to the current task:

const linkFromPrevious = scheduler.links.findAllByFromTo(null, args.source.data.id)[0];

2. Find the link from the current task to the next task:

const linkToNext = scheduler.links.findAllByFromTo(args.source.data.id, null)[0];

3. If there is a previous event, we store it so we can update it later:

const previousEvent = linkFromPrevious ? scheduler.events.find(linkFromPrevious.from()) : null;

4. Create arrays of links to be added and removed:

const add = [];
const remove = [];

5. If both linkFromPrevious and linkToNext exist, we create a new link connecting the previous task directly to the next task:

if (linkFromPrevious && linkToNext) {
    add.push({
        from: linkFromPrevious.from(),
        to: linkToNext.to(),
    });
}

6. We add the IDs of existing links to the remove array:

if (linkFromPrevious) {
    remove.push(linkFromPrevious.id());
}
if (linkToNext) {
    remove.push(linkToNext.id());
}

7. We send a bulk link update request to the server:

const {data} = await DayPilot.Http.post("/api/Links/BulkUpdate", {add: add, remove: remove});

8. After receiving the response, we update the scheduler's link collections:

data.added.forEach(link => {
    scheduler.links.add(link);
});
data.removed.forEach(link => {
    scheduler.links.remove(link);
});

9. To make sure

if (previousEvent) {
    scheduler.events.update(previousEvent);
}

Here's the full code for the "Delete" action within the context menu:

{
  // ...
  contextMenu: new DayPilot.Menu({
      items: [
          {
              text: "Edit...",
              onClick: async args => {
                  await app.editTask(args.source);
              }
          },
          {
              text: "-"
          },
          {
              text: "Delete", 
              onClick: async args => {
                  const linkFromPrevious = scheduler.links.findAllByFromTo(null, args.source.data.id)[0];
                  const linkToNext = scheduler.links.findAllByFromTo(args.source.data.id, null)[0];
                  const previousEvent = linkFromPrevious ? scheduler.events.find(linkFromPrevious.from()) : null;
                  
                  if (linkFromPrevious || linkToNext) {
                      const add = [];
                      const remove = [];
                      
                      if (linkFromPrevious && linkToNext) {
                          add.push({
                              from: linkFromPrevious.from(),
                              to: linkToNext.to(),
                          });
                      }
                      if (linkFromPrevious) {
                          remove.push(linkFromPrevious.id());
                      }
                      if (linkToNext) {
                          remove.push(linkToNext.id());
                      }
                      
                      const {data} = await DayPilot.Http.post("/api/Links/BulkUpdate", {add: add, remove: remove});
                      data.added.forEach(link => {
                          scheduler.links.add(link);
                      });
                      data.removed.forEach(link => {
                          scheduler.links.remove(link);
                      });

                      if (previousEvent) {
                          scheduler.events.update(previousEvent);
                      }
                  }
                  
                  await DayPilot.Http.delete(`/api/Tasks/${args.source.data.id}`);
                  scheduler.events.remove(args.source);
              }
          }
      ]
  }),
  // ...
}

Entity Framework Model Classes

Our ASP.NET Core field service scheduling application uses the following Entity Framework model classes to store the application data.

The Team class stores the data of the field service teams. It is minimalistic and only stores the required fields (Id and Name). You can extend it with additional fields, such as location, hourly cost, capabilities, etc.

public class Team
{
    public int Id { get; set; }
    public string Name { get; set; }
}

The ServiceTask class stores the task details. In addition to the task text, it stores required fields, such as Start, End, and TeamId (the assigned team).

public class ServiceTask
{
    public int Id { get; set; }
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
    
    [JsonPropertyName("resource")]
    public int TeamId { get; set; }
    
    public string? Text { get; set; }
}

The Link class stores the relations between tasks in the same route. They represent the travel time needed between the tasks.

public class Link
{
    public int Id { get; set; }
    public int From { get; set; }
    public int To { get; set; }
}

Full Source Code

Here is the full source code of the frontend of our application (HTML + JavaScript).

@page
@model IndexModel
@{
    ViewData["Title"] = "ASP.NET Core Field Service Scheduling";
}

<link rel="stylesheet" href="~/css/switch.css" asp-append-version="true" />
<script src="lib/daypilot/daypilot-all.min.js" asp-append-version="true"></script>

<style>
    /* allow the icon to overlap the event box boundaries */
    .scheduler_default_event {
        overflow: visible !important;
    }

</style>

<div style="display:flex">
    <div style="margin-right: 10px;">
        <div id="nav"></div>        
    </div>
    <div style="flex-grow: 1">
        <div id="scheduler"></div>
    </div>
</div>

<script>

const app = {
    minimumTravelMinutes: 15,
    minimumEventDuration: 30,
    eventForm: [
        {name: "Name", id: "text"},
    ],
    findPreviousEvent: (row, date, sameDayOnly) => {
        const rangeStart = sameDayOnly ? date.getDatePart() : scheduler.visibleStart();
        const previousEvents = row.events.forRange(rangeStart, date);
        return previousEvents[previousEvents.length - 1];
    },
    findNextEvent: (row, date, sameDayOnly) => {
        const rangeEnd = sameDayOnly ? date.getDatePart().addDays(1) : scheduler.visibleEnd();
        const nextEvents = row.events.forRange(date, rangeEnd);
        return nextEvents[0];
    },
    async editTask(e) {
        const modal = await DayPilot.Modal.form(app.eventForm, e.data);
        if (modal.canceled) {
            return;
        }
        const {data: result} = await DayPilot.Http.put(`/api/Tasks/${e.data.id}`, modal.result);
        scheduler.events.update(modal.result);
    },
    async loadTasks() {
        const start = scheduler.visibleStart();
        const end = scheduler.visibleEnd();

        const [tasksResponse, linksResponse] = await Promise.all([
            DayPilot.Http.get(`/api/Tasks?start=${start}&end=${end}`),
            DayPilot.Http.get(`/api/Links?start=${start}&end=${end}`)
        ]);

        scheduler.update({
            events: tasksResponse.data,
            links: linksResponse.data
        });

    },
    async loadResources() {

        const { data: resources } = await DayPilot.Http.get("/api/Teams");
        scheduler.update({
            resources: resources
        });
    },
    init() {
        this.loadResources();
        this.loadTasks();
        
        scheduler.scrollTo(DayPilot.Date.today().addHours(7));
    },
    
};

const nav = new DayPilot.Navigator("nav", {
    selectMode: "Day",
    showMonths: 3,
    skipMonths: 3,
    onTimeRangeSelected: function(args) {
        if (scheduler.visibleStart() <= args.start && scheduler.visibleEnd() >= args.end) {
            scheduler.scrollTo(args.start.getDatePart().addHours(7), "fast");
            return;
        }
    
        const start = args.start.firstDayOfMonth();
        const days = start.daysInMonth();
        scheduler.update({
            startDate: start,
            days: days
        });
        scheduler.scrollTo(args.start.getDatePart().addHours(7), "fast");
        app.loadTasks();
    }
});
nav.init();
    
const scheduler = new DayPilot.Scheduler("scheduler", {
    startDate: DayPilot.Date.today(),
    days: DayPilot.Date.today().daysInMonth(),
    scale: "CellDuration",
    cellDuration: 15,
    timeHeaders: [
        {groupBy: "Day", format: "MMMM d, yyyy"},
        {groupBy: "Hour", format: "hh tt"},
        {groupBy: "Cell", format: "mm"}
    ],
    cellWidth: 50,
    treeEnabled: true,
    linkLayer: "Below",
    durationBarVisible: false,
    rowMarginTop: 2,
    rowMarginBottom: 2,
    eventBorderRadius: 25,
    contextMenu: new DayPilot.Menu({
        items: [
            {
                text: "Edit...",
                onClick: async args => {
                    await app.editTask(args.source);
                }
            },
            {
                text: "-"
            },
            {
                text: "Delete", 
                onClick: async args => {
                    const linkFromPrevious = scheduler.links.findAllByFromTo(null, args.source.data.id)[0];
                    const linkToNext = scheduler.links.findAllByFromTo(args.source.data.id, null)[0];
                    const previousEvent = linkFromPrevious ? scheduler.events.find(linkFromPrevious.from()) : null;
                    
                    if (linkFromPrevious || linkToNext) {
                        const add = [];
                        const remove = [];
                        
                        if (linkFromPrevious && linkToNext) {
                            add.push({
                                from: linkFromPrevious.from(),
                                to: linkToNext.to(),
                            });
                        }
                        if (linkFromPrevious) {
                            remove.push(linkFromPrevious.id());
                        }
                        if (linkToNext) {
                            remove.push(linkToNext.id());
                        }
                        
                        const {data} = await DayPilot.Http.post("/api/Links/BulkUpdate", {add: add, remove: remove});
                        data.added.forEach(link => {
                            scheduler.links.add(link);
                        });
                        data.removed.forEach(link => {
                            scheduler.links.remove(link);
                        });

                        if (previousEvent) {
                            scheduler.events.update(previousEvent);
                        }
                    }
                    
                    await DayPilot.Http.delete(`/api/Tasks/${args.source.data.id}`);
                    scheduler.events.remove(args.source);
                }
            }
        ]
    }),
    onTimeRangeSelecting: args => {
        const row = scheduler.rows.find(args.resource);
        
        // check distance from existing events
        const overlappingEvents = row.events.forRange(args.start.addMinutes(-app.minimumTravelMinutes), args.end.addMinutes(app.minimumTravelMinutes));
        if (overlappingEvents.length > 0) {
            args.allowed = false;
        }
    },
    onTimeRangeSelected: async args => {
        const data = {
            start: args.start,
            end: args.end,
            resource: args.resource,
            text: "Task"
        };
        const modal = await DayPilot.Modal.form(app.eventForm, data);
        scheduler.clearSelection();
        if (modal.canceled) {
            return;
        }
        
        const e = modal.result;
        
        const {data: taskResult} = await DayPilot.Http.post("/api/Tasks", e);
        e.id = taskResult.id;    
        
        const row = scheduler.rows.find(args.resource);
        const previousEvent = app.findPreviousEvent(row, args.start, true);
        
        const add = [];
        const remove = [];
        
        if (previousEvent) {
            const linksFrom = scheduler.links.findAllByFromTo(previousEvent.data.id, null);
            linksFrom.forEach(link => {
                scheduler.links.remove(link);
                remove.push(link.id());
            });
            const newLink = {
                from: previousEvent.data.id,
                to: e.id,
            };
            add.push(newLink);
        }
        
        const nextEvent = app.findNextEvent(row, args.end, true); 
        
        if (nextEvent) {
            const linksTo = scheduler.links.findAllByFromTo(null, nextEvent.data.id);
            linksTo.forEach(link => {
                scheduler.links.remove(link);
                remove.push(link.id());
            });
            const newLink = {
                from: e.id,
                to: nextEvent.data.id,
            };
            add.push(newLink);
        }
        
        const {data: linkResult} = await DayPilot.Http.post("/api/Links/BulkUpdate", {add: add, remove: remove});
        linkResult.added.forEach(link => {
            scheduler.links.add(link);
        });
        scheduler.events.add(e);
        
        if (previousEvent) {
            scheduler.events.update(previousEvent);
        }
        if (nextEvent) {
            scheduler.events.update(nextEvent);
        }

    },
    onBeforeEventRender: args => {
        args.data.fontColor = "#ffffff";
        args.data.padding = 10;
        args.data.backColor = "#6fa8dc";
        args.data.borderColor = "#5ba1e1";
        
        const linksFrom = scheduler.links.findAllByFromTo(args.data.id, null);
        if (linksFrom.length === 0) {
            args.data.areas = [
                { 
                    right: -6, 
                    top: "calc(50% - 7px)", 
                    width: 14, 
                    height: 14, 
                    borderRadius: "50%",
                    style: "cursor:move; border: 1px solid #ffffff;", 
                    backColor: "#f37021", 
                    fontColor: "#ffffff",  
                    symbol: "/icons/daypilot.svg#minichevron-right-2", 
                    action: "Move", 
                    data: "next" 
                }
            ];
        }
    },
    onEventMoving: args => {

        if (args.areaData === "next") {

            if (args.e.resource() !== args.resource) {
                args.resource = args.e.resource();
            }
            
            const defaultDuration = 30;
            args.start = args.end.addMinutes(-defaultDuration);
            const minStart = args.e.end().addMinutes(app.minimumTravelMinutes);
            if (minStart > args.start) {
                args.start = minStart;
                args.end = args.start.addMinutes(defaultDuration);
            }
            
            const row = scheduler.rows.find(args.resource);
            const nextEvent = app.findNextEvent(row, args.e.end());
            if (nextEvent) {
                const maxEnd = nextEvent.start().addMinutes(-app.minimumTravelMinutes);
                if (args.end > maxEnd) {
                    args.end = maxEnd;
                    args.start = args.end.addMinutes(-defaultDuration);
                }
            }
            
            if (args.start < minStart) {
                args.start = args.e.end();
                args.end = args.start.addMinutes(defaultDuration);
                args.allowed = false;
            }
            else {
                args.links = [{
                    from: args.e,
                    color: "#666666"
                }];
            }
        }
        else {
            args.resource = args.e.resource();

            const row = scheduler.rows.find(args.resource);
            const duration = args.e.duration();

            const previousEvent = app.findPreviousEvent(row, args.e.start());

            if (previousEvent) {
                const minStart = previousEvent.end().addMinutes(app.minimumTravelMinutes);
                if (minStart > args.start) {
                    args.start = minStart;
                    args.end = args.start.addTime(duration);
                }
            }

            const nextEvent = app.findNextEvent(row, args.e.end());
            if (nextEvent) {
                const maxEnd = nextEvent.start().addMinutes(-app.minimumTravelMinutes);
                if (args.end > maxEnd) {
                    args.end = maxEnd;
                    args.start = args.end.addTime(-duration.ticks);
                }
            }
        }

    },
    onEventMove: async args => {
        if (args.areaData === "next") {
            args.preventDefault();
            
            const e = {
                //id: DayPilot.guid(),
                start: args.newStart,
                end: args.newEnd,
                text: "New task",
                resource: args.newResource,
            };
            
            const {data: result} = await DayPilot.Http.post("/api/Tasks", e);
            e.id = result.id;
            
            scheduler.events.add(e);
            
            const newLink = {
                from: args.e.data.id,
                to: e.id,
                text: "Travel",
                textAlignment: "center",
            };
            const {data: link} = await DayPilot.Http.post("/api/Links", newLink);
            scheduler.links.add(link);
            scheduler.events.update(args.e.data);
        }
        else {
            const {data: result} = await DayPilot.Http.put(`/api/Tasks/${args.e.data.id}`, {
                id: args.e.data.id,
                start: args.newStart,
                end: args.newEnd,
                resource: args.newResource,
                text: args.e.data.text
            });
        }       
    },
    onEventResizing: args => {
        if (args.anchor === args.start) {
            const minEnd = args.start.addMinutes(app.minimumEventDuration);
            if (minEnd > args.end) {
                args.end = minEnd;
            }
        }
        else {
            const maxStart = args.end.addMinutes(-app.minimumEventDuration);
            if (maxStart < args.start) {
                args.start = maxStart;
            }
        }
        // check distance from existing events
        const row = scheduler.rows.find(args.e.resource());
        const overlappingEvents = row.events.forRange(args.start.addMinutes(-app.minimumTravelMinutes), args.end.addMinutes(app.minimumTravelMinutes));
        if (overlappingEvents.length > 1) {
            args.allowed = false;
        }
        
    },
    onEventResize: async args => {
        const {data: result} = await DayPilot.Http.put(`/api/Tasks/${args.e.data.id}`, {
            id: args.e.data.id,
            start: args.newStart,
            end: args.newEnd,
            resource: args.e.data.resource,
            text: args.e.data.text
        });
    },
    onEventClick: async args => {
        await app.editTask(args.e);
    },
    onBeforeLinkRender: args => {
        args.data.color = "#f37021";
        args.data.text = "Travel";
        args.data.textAlignment = "center";
    }
});
scheduler.init();

app.init();

</script>

You can download the complete .NET project using the link at the top of this tutorial.