Features

  • The Scheduler component displays a time for business hours (nights and weekends are hidden).
  • It allows scheduling follow-up jobs for subsequent production steps on different machines
  • Spring Boot web application (Java 8+) that uses JavaScript/HTML5 frontend and REST HTTP backend API.
  • The tutorial uses in-memory H2 database to minimize dependencies.
  • Built using JavaScript Scheduler UI component from DayPilot Pro for JavaScript

This tutorial covers advanced scheduling features of DayPilot Pro. For a basic tutorial on using the Scheduler with Spring Boot please see Using JavaScript/HTML5 Scheduler in Spring Boot (Java).

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.

Scheduling Workflow Overview

Scheduling a New Job

machine-production-job-scheduling-spring-java-new-task.png

Users of our production scheduling application can create a new task using drag and drop. It lets them specify the machine and start/end time using a visual UI.

The schedule displays available machines on the Y axis (grouped by type) and the working hours on the X axis. The time axis is limited to business hours (defined as 9AM to 5PM from Monday to Friday in this example).

New Job Name

machine-production-job-scheduling-spring-java-new-task-name.png

A simple modal dialog allows users to enter the job name.

Job Color

machine-production-job-scheduling-spring-java-task-color.png

Each job can have a custom color assigned that lets users specify priority, job type or special requirements.

Scheduling Follow-Up Jobs

machine-production-job-scheduling-spring-java-follow-up-job.png

The application supports easy scheduling of a follow-job. Start dragging the "ยป" icon in the lower-right corner of the scheduled job to create a task for a subsequent step on another machine.

machine-production-job-scheduling-spring-java-follow-up-icon.png

When the user starts dragging the follow-up icon, the Scheduler displays a placeholder that outlines the new job position.

machine-production-job-scheduling-spring-java-follow-up-task-drag.png

The follow-up job is automatically linked to the previous job. The new job has the default duration (1 hour).

machine-production-job-scheduling-spring-java-follow-up-task-created.png

Changing Job Duration

machine-production-job-scheduling-spring-java-change-task-duration.png

Users can change the job duration by dragging one of the edges.

Deleting a Scheduled Job

machine-production-job-scheduling-spring-java-delete-task.png

It's possible to delete a job from the schedule using a context menu. It also deletes all follow-up jobs.

Implementation

The application uses JavaScript Scheduler component from DayPilot Pro for JavaScript package. The Scheduler component includes built-in features that make the implementation easier, such as drag and drop, customized rendering, hiding non-business hours and days.

Most of the functionality is implemented on the client side in a HTML5/JavaScript page. The backend is implemented in Spring Boot - it defines REST/JSON endpoints that let you load the data and save changes made by users.

This tutorial doesn't cover the Scheduler basics. For an introduction to using the Scheduler in a Spring Boot application please see the following tutorial:

Defining and Loading Machines

machine-production-job-scheduling-spring-java-resources.png

The resource tree (machines and their groups) is loaded from the server using an AJAX call:

dp.rows.load("/api/resources");

This URL ("/api/resources") returns a JSON object with the tree:

[
  {"name":"Cutting","children":[
    {"name":"Cutting Machine 1","id":1},
    {"name":"Cutting Machine 2","id":2}
    ],"ordinal":1,"id":"G1","expanded":true},
  {"name":"Welding","children":[
    {"name":"Welding Cell 1","id":3},
    {"name":"Welding Cell 2","id":4},
    {"name":"Welding Cell 3","id":5}
    ],"ordinal":2,"id":"G2","expanded":true},
  {"name":"Sandblasting","children":[
    {"name":"Blast Booth 1","id":6}
    ],"ordinal":3,"id":"G3","expanded":true}
]

Disabling Parent Rows

machine-production-job-scheduling-spring-java-resources.png

We don't want the users to schedule jobs for the rows that represent groups. We will disable drag and drop for the parent rows:

dp.preventParentUsage = true;

The grid cells that belong to rows with machine groups are automatically marked with "scheduler_default_cellparent CSS" class. We will override the default CSS theme and apply a light gray color to the parent cells:

.scheduler_default_main .scheduler_default_cell.scheduler_default_cellparent {
    background-color: #f0f0f0;
}

Business Hours

machine-production-job-scheduling-spring-java-work-hours.png

We have configured the Scheduler to use a cell size of 1 hour:

dp.scale = "Hour";

The time headers will display the hour name for each column. The topmost row groups the columns by day:

dp.timeHeaders = [
  {groupBy: "Day", format: "dddd M/d/yyyy"},
  {groupBy: "Hour"}
];

Our machines are in operation only during working hours, defined as 9 a.m. - 6 p.m.:

dp.businessBeginsHour = 9;
dp.businessEndsHour = 18;

Displaying the full timeline would result in big gaps (9 hours of operating hours vs 15 hours of non-operating hours). That's why will hide the time outside of the operating hours:

dp.showNonBusiness = false;

We will also set the Scheduler to skip the hidden time during drag and drop operations (creating and moving jobs). This option will automatically adjust the start and end time as needed (it will be extended by the hidden time).

dp.eventMoveSkipNonBusiness = true;

machine-production-job-scheduling-spring-java-skip-non-business.png

Context Menu

machine-production-job-scheduling-spring-java-context-menu.png

In the next step, we will add a context menu that lets users delete scheduled jobs (it uses an AJAX call to a backend URL to save the changes to a database).

It also allows changing the job color (4 colors are predefined). The selected color will be applied to all linked jobs.

dp.contextMenu = new DayPilot.Menu({
    items: [
        {
            text: "Delete",
            onClick: function(args) {
                var params = {
                    id: args.source.id(),
                };
                $.ajax({
                    type: 'POST',
                    url: '/api/events/delete',
                    data: JSON.stringify(params),
                    success: function (data) {
                        data.deleted.forEach(function(id) {
                            dp.events.removeById(id);
                        });
                        data.updated.forEach(function(data) {
                            var e = dp.events.find(data.id);
                            if (e) {
                                e.data.hasNext = data.hasNext;
                                dp.events.update(e);
                            }
                        });

                        dp.message("Job deleted");
                    },
                    contentType: "application/json",
                    dataType: 'json'
                });

            }
        },
        {
            text: "-"
        },
        {
            text: "Blue",
            icon: "icon icon-blue",
            color: "#1155cc",
            onClick: function(args) { updateColor(args.source, args.item.color); }
        },
        {
            text: "Green",
            icon: "icon icon-green",
            color: "#6aa84f",
            onClick: function(args) { updateColor(args.source, args.item.color); }
        },
        {
            text: "Yellow",
            icon: "icon icon-yellow",
            color: "#f1c232",
            onClick: function(args) { updateColor(args.source, args.item.color); }
        },
        {
            text: "Red",
            icon: "icon icon-red",
            color: "#cc0000",
            onClick: function(args) { updateColor(args.source, args.item.color); }
        },

    ]
});

Creating a Follow-Up Job using Drag and Drop

machine-production-job-scheduling-spring-java-follow-up.png

The follow-up jobs can be scheduled using the follow-up icon in the lower-right corner of existing jobs. Only one follow-up job is allowed (the icon is automatically hidden if the follow-up job has been already defined).

The follow-up icon is created using an active area:

dp.onBeforeEventRender = function(args) {
    if (args.data.hasNext) {
        return;
    }
    args.data.areas = [
        {
            right: 2,
            bottom: 2,
            width: 16,
            height: 16,
            backColor: "#fff",
            style: "box-sizing: border-box; border-radius: 7px; padding-left: 3px; border: 1px solid #ccc;font-size: 14px;line-height: 14px;color: #999;",
            html: "»",
            toolTip: "Drag to schedule next step",
            action: "Move",
            data: "event-copy"
        }
    ];
};

We will provide real-time feedback to users during drag and drop using onEventMoving event handler. The event handler checks if the moving was initiated using the follow-up icon (args.areaData === "event-copy"). It displays a link to the original job (args.link):

dp.onEventMoving = function(args) {
    if (args.areaData && args.areaData === "event-copy") {
        args.link = {
            from: args.e,
            color: "#666"
        };
        args.start = args.end.addHours(-1);
        if (args.e.end() > args.start) {
            args.allowed = false;
            args.link.color = "red";
        }
    }
};

It also checks if the target position is valid (it must start after the original job has finished). If the target position doesn't meet the rules it will be marked as "not allowed" and it will be displayed in red. If the user releases the mouse button at this point no follow-up task will be created.

machine-production-job-scheduling-spring-java-invalid-position.png

Job Editing

machine-production-job-scheduling-spring-java-edit-job.png

When the users click the job box in the Scheduler the application opens a simple modal dialog that lets them change the job name.

The modal dialog can be replaced with a more complex form that would allow editing additional job properties. However, we will create a simplified dialog using DayPilot.Modal.prompt() method which doesn't require creating a special page with the form.

dp.onEventClick = function(args) {
    DayPilot.Modal.prompt("Task name:", args.e.text()).then(function(modal) {
        if (!modal.result) {
            return;
        }
        var text = modal.result;
        var params = {
            join: args.e.data.join,
            text: text
        };
        $.ajax({
            type: 'POST',
            url: '/api/events/setText',
            data: JSON.stringify(params),
            success: function (data) {
                var list = data.events;
                list.forEach(function(data) {
                    var e = dp.events.find(data.id);
                    if (e) {
                        e.data.text = text;
                        dp.events.update(e);
                    }
                });
                dp.message("Text updated");
            },
            contentType: "application/json",
            dataType: 'json'
        });
    });
};

Job Dependencies

machine-production-job-scheduling-spring-java-task-dependencies.png

The dependencies are displayed using event links. When the scheduler is initialized it requests the links from the server side:

dp.links.load("/api/links");

The /api/links URL returns a JSON array with the events links:

[
  {"id":1,"to":2,"from":1}
]

Server-Side Controller (Java)

Here is the source code of the MainController class which implements the REST API behind this application.

It's a Spring MVC controller that specifies endpoints for loading machines (resources), jobs (events), and dependencies (links). It also saves changes done by the user through the Scheduler UI.

package org.daypilot.demo.machinescheduling.controller;

import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.daypilot.demo.machinescheduling.domain.Event;
import org.daypilot.demo.machinescheduling.domain.Link;
import org.daypilot.demo.machinescheduling.domain.Resource;
import org.daypilot.demo.machinescheduling.domain.ResourceGroup;
import org.daypilot.demo.machinescheduling.repository.EventRepository;
import org.daypilot.demo.machinescheduling.repository.LinkRepository;
import org.daypilot.demo.machinescheduling.repository.ResourceGroupRepository;
import org.daypilot.demo.machinescheduling.repository.ResourceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
import org.springframework.web.bind.annotation.*;

import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@RestController
public class MainController {

    @Autowired
    EventRepository er;

    @Autowired
    ResourceRepository rr;

    @Autowired
    ResourceGroupRepository gr;

    @Autowired
    LinkRepository lr;

    @RequestMapping("/api/resources")
    List<ResourceGroup> resources() {
        List<ResourceGroup> groups = gr.findAllByOrderByOrdinal();

        groups.forEach(resourceGroup -> {
            List<Resource> children = rr.findByParent(resourceGroup);
            resourceGroup.setChildren(children);
        });

        return groups;
    }

    @GetMapping("/api/events")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    Iterable<Event> events(@RequestParam("start") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime start, @RequestParam("end") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime end) {
        return er.findBetween(start, end);
    }

    @GetMapping("/api/links")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    Iterable<Link> links(@RequestParam("start") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime start, @RequestParam("end") @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime end) {
        return lr.findAll();
    }

    @PostMapping("/api/events/create")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse createEvent(@RequestBody EventCreateParams params) {

        List<Event> update = new ArrayList<>();

        Resource r = rr.findById(params.resource).orElse(null);

        Event e = new Event();
        e.setStart(params.start);
        e.setEnd(params.end);
        e.setText(params.text);
        e.setResource(r);
        er.save(e);

        e.setJoin(e.getId());
        er.save(e);

        update.add(e);

        if (params.link != null) {
            Event from = er.findById(params.link.from).orElse(null);
            from.setHasNext(true);
            er.save(from);

            update.add(from);

            Link link = new Link();
            link.setFrom(from);
            link.setTo(e);
            lr.save(link);

            e.setText(from.getText());
            e.setJoin(from.getJoin());
            e.setColor(from.getColor());
            er.save(e);
        }

        return new UpdateResponse(){{
            events = update;
        }};
    }

    @PostMapping("/api/events/move")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    Event moveEvent(@RequestBody EventMoveParams params) {

        Event e = er.findById(params.id).orElse(null);
        Resource r = rr.findById(params.resource).orElse(null);

        e.setStart(params.start);
        e.setEnd(params.end);
        e.setResource(r);

        er.save(e);

        return e;
    }

    @PostMapping("/api/events/delete")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    EventDeleteResponse deleteEvent(@RequestBody EventDeleteParams params) {

        List<Long> deletedIds = new ArrayList<>();
        List<Event> updatedEvents = new ArrayList<>();

        Event e = er.findById(params.id).orElse(null);

        List<Link> previous = lr.findByTo(e);
        previous.forEach(link -> {
            link.getFrom().setHasNext(false);
            er.save(link.getFrom());

            updatedEvents.add(link.getFrom());
        });

        deleteEventWithLinks(e, deletedIds);


        return new EventDeleteResponse() {{
            updated = updatedEvents;
            deleted = deletedIds;
        }};
    }

    private void deleteEventWithLinks(Event e, List<Long> deleted) {
        List<Link> toLinks = lr.findByTo(e);
        lr.deleteAll(toLinks);

        List<Link> fromLinks = lr.findByFrom(e);
        fromLinks.forEach(link -> {
            deleteEventWithLinks(link.getTo(), deleted);
        });

        er.delete(e);
        deleted.add(e.getId());

    }


    @PostMapping("/api/events/setColor")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse setColor(@RequestBody SetColorParams params) {

        List<Event> list = er.findByJoin(params.join);
        list.forEach(e -> {
            e.setColor(params.color);
            er.save(e);
        });

        return new UpdateResponse() {{
            events = list;
        }};
    }

    @PostMapping("/api/events/setText")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @Transactional
    UpdateResponse setText(@RequestBody SetTextParams params) {

        List<Event> list = er.findByJoin(params.join);
        list.forEach(e -> {
            e.setText(params.text);
            er.save(e);
        });

        return new UpdateResponse() {{
            events = list;
        }};
    }

    public static class EventCreateParams {
        public LocalDateTime start;
        public LocalDateTime end;
        public String text;
        public Long resource;
        public LinkCreateParams link;
    }

    public static class LinkCreateParams {
        public Long from;
    }

    public static class EventMoveParams {
        public Long id;
        public LocalDateTime start;
        public LocalDateTime end;
        public Long resource;
    }

    public static class SetColorParams {
        public Long join;
        public String color;
    }

    public static class SetTextParams {
        public Long join;
        public String text;
    }

    public static class EventDeleteParams {
        public Long id;
    }

    public static class EventDeleteResponse {
        public List<Long> deleted;
        public List<Event> updated;
    }

    public static class UpdateResponse {
        public List<Event> events;
    }


}

JPA Entities

Here are the domain model classes that handle the persistence on the server side:

Event.java

The Event class represents the job task to be scheduled. It specifies the start and end date/time, assigned resource, the job sequence id ("join") and the next linked job.

package org.daypilot.demo.machinescheduling.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(indexes = { @Index(name = "index_join", columnList = "join_id") })
public class Event {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    String text;

    LocalDateTime start;

    LocalDateTime end;

    @ManyToOne
    @JsonIgnore
    Resource resource;

    String color;

    @Column(name="join_id")
    Long join;

    boolean hasNext = false;

    @JsonProperty("resource")
    public Long getResourceId() {
        return resource.getId();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }

    public LocalDateTime getStart() {
        return start;
    }

    public void setStart(LocalDateTime start) {
        this.start = start;
    }

    public LocalDateTime getEnd() {
        return end;
    }

    public void setEnd(LocalDateTime end) {
        this.end = end;
    }

    public Resource getResource() {
        return resource;
    }

    public void setResource(Resource resource) {
        this.resource = resource;
    }

    public String getColor() {
        return color;
    }

    public void setColor(String color) {
        this.color = color;
    }

    public Long getJoin() {
        return join;
    }

    public void setJoin(Long join) {
        this.join = join;
    }

    public boolean isHasNext() {
        return hasNext;
    }

    public void setHasNext(boolean hasNext) {
        this.hasNext = hasNext;
    }
}

Link.java

The Link class represents a link between individual events (jobs). It stores the source and target event IDs.

package org.daypilot.demo.machinescheduling.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
public class Link {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @ManyToOne
    @JsonIgnore
    Event from;

    @ManyToOne
    @JsonIgnore
    Event to;

    @JsonProperty("from")
    public Long getFromId() {
        return from.getId();
    }

    @JsonProperty("to")
    public Long getToId() {
        return to.getId();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public Event getFrom() {
        return from;
    }

    public void setFrom(Event from) {
        this.from = from;
    }

    public Event getTo() {
        return to;
    }

    public void setTo(Event to) {
        this.to = to;
    }
}

Resource.java

The Resource class represents machines that are used to perform the production jobs. It specifies a resource name and the group the resource belongs to.

package org.daypilot.demo.machinescheduling.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;

import javax.persistence.*;

@Entity
public class Resource {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long Id;

    String name;

    @ManyToOne
    @JsonIgnore
    ResourceGroup parent;

    public Long getId() {
        return Id;
    }

    public void setId(Long id) {
        this.Id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public ResourceGroup getParent() {
        return parent;
    }

    public void setParent(ResourceGroup parent) {
        this.parent = parent;
    }
}

ResourceGroup.java

The ResourceGroup class represents the machine type. It's used to group the resources in the Scheduler tree.

package org.daypilot.demo.machinescheduling.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

import javax.persistence.*;
import java.util.List;

@Entity
public class ResourceGroup {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @JsonIgnore
    Long Id;

    String name;

    @Transient
    List<Resource> children;

    Long ordinal;

    @JsonProperty("id")
    public String getPrefixedId() {
        return "G" + Id;
    }

    public Long getId() {
        return Id;
    }

    public void setId(Long id) {
        this.Id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<Resource> getChildren() {
        return children;
    }

    public void setChildren(List<Resource> children) {
        this.children = children;
    }

    public Long getOrdinal() {
        return ordinal;
    }

    public void setOrdinal(Long ordinal) {
        this.ordinal = ordinal;
    }

    public boolean getExpanded() {
        return true;
    }
}

History

  • September 17, 2019: Spring Boot upgraded to 2.1.8, DayPilot Pro for JavaScript upgraded to 2019.3.4023, JPA entities explained.
  • September 25, 2017: Initial release