24-Hour Rotating Crew Shift Rota

The scheduler can be configured for industrial or service environments requiring 24/7 coverage, such as manufacturing lines or critical infrastructure maintenance. This setup uses three resources (Crew A, Crew B, Crew C) to demonstrate a cyclical, rotating roster, like the 3-Crew, 3-Shift Rota.

To visualize the continuous operation and manage shifts that span midnight, the scheduler view is configured with a custom, 24-hour cycle, starting at 06:00 and ending at 06:00 the following day. This is achieved using the custom day-offset feature added to the startTime (e.g., '20:00-1') and endTime (e.g., '08:00+1') properties under the view option, which initiates the view relative to the previous or next calendar day. Furthermore, to ensure compliance with 8-hour shift lengths and prevent invalid scheduling, the drag-and-drop functionality is constrained by setting the dragTimeStep to 8 hours, requiring all events to be moved only in 8-hour increments and anchoring the alignment with the dragTimeStepBase: 'viewStart' option. The drag-and-drop logic is implemented for automatic conflict resolution: when an event is dragged to a different time or resource column within the same day, it automatically updates and shifts any conflicting events (those sharing the same time slot or resource column) to maintain scheduling integrity and prevent overlaps.

Get started with this example

Demo

Loading the live demo

Preview in full screen

Build a 24-hour rotating crew rota with the Scheduler — three crews, three shifts, and automatic conflict resolution when shifts move.

Code

import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import {
  MbscCalendarColor,
  MbscCalendarEvent,
  MbscCellHoverEvent,
  MbscEventcalendarOptions,
  MbscEventCreateEvent,
  MbscEventDeleteEvent,
  MbscEventDragEvent,
  MbscEventUpdateEvent,
  MbscModule,
  MbscNewEventData,
  MbscResource,
  Notifications,
  setOptions ,
} from '@mobiscroll/angular';

setOptions({
  locale:  ,
  theme: ' ',
  themeVariant: ' '
});

interface Shift {
  startHour: number;
  endHour: number;
  title: string;
  color: string;
  nextDay?: boolean;
}

@Component({
  selector: 'app-root', 
  templateUrl: './app.component.html',
  providers: [Notifications],
})
export class AppComponent {
  constructor(private notify: Notifications) {}

  morningColor = '#4a8c4d';
  afternoonColor = '#f87c6b';
  nightColor = '#8567AD';

  shifts: Record<string, Shift> = {
    morning: { startHour: 6, endHour: 14, title: 'Morning Shift', color: '#4a8c4d' },
    afternoon: { startHour: 14, endHour: 22, title: 'Afternoon Shift', color: '#f87c6b' },
    night: { startHour: 22, endHour: 6, nextDay: true, title: 'Night Shift', color: '#8567AD' },
  };

  draggedEventStart: Date | null = null;
  draggedEventEnd: Date | null = null;
  draggedEventResource: string | null = null;
  availableSlotOnHover: MbscCalendarColor | null = null;
  redResources: Record<string, boolean> = {};
  colors: MbscCalendarColor[] = [];

  myEvents: MbscCalendarEvent[] = [
    {
      id: 1,
      resource: 'A',
      title: 'Morning Shift',
      start: '2026-10-23T06:00',
      end: '2026-10-23T14:00',
      color: this.morningColor,
    },
    {
      id: 2,
      resource: 'B',
      title: 'Afternoon Shift',
      start: '2026-10-23T14:00',
      end: '2026-10-23T22:00',
      color: this.afternoonColor,
    },
    { id: 3, resource: 'C', title: 'Night Shift', start: '2026-10-23T22:00', end: '2026-10-24T06:00', color: this.nightColor },
    {
      id: 4,
      resource: 'A',
      title: 'Morning Shift',
      start: '2026-10-24T06:00',
      end: '2026-10-24T14:00',
      color: this.morningColor,
    },
    {
      id: 5,
      resource: 'B',
      title: 'Afternoon Shift',
      start: '2026-10-24T14:00',
      end: '2026-10-24T22:00',
      color: this.afternoonColor,
    },
    { id: 6, resource: 'C', title: 'Night Shift', start: '2026-10-24T22:00', end: '2026-10-25T06:00', color: this.nightColor },
    // Showing partial data. Download full source.
  ];

  myResources: MbscResource[] = [
    { id: 'A', name: 'Crew A' },
    { id: 'B', name: 'Crew B' },
    { id: 'C', name: 'Crew C' },
  ];

  getShiftByHour(hour: number) {
    if (hour >= 6 && hour < 14) {
      return this.shifts['morning'];
    }
    if (hour >= 14 && hour < 22) {
      return this.shifts['afternoon'];
    }
    if (hour >= 22 || hour < 6) {
      return this.shifts['night'];
    }
    return this.shifts['afternoon'];
  }

  calculateStart(start: Date): Date {
    const d = new Date(start);
    const originalHour = d.getHours();
    const shift = this.getShiftByHour(originalHour);
    d.setHours(shift.startHour, 0, 0, 0);
    if (shift.startHour === 22 && originalHour < 6) {
      d.setDate(d.getDate() - 1);
    }
    return d;
  }

  calculateEnd(start: Date): Date {
    const d = new Date(start);
    const startHour = d.getHours();
    const shift = this.getShiftByHour(d.getHours());
    d.setHours(shift.endHour, 0, 0, 0);
    if (shift.nextDay && startHour === 22) {
      d.setDate(d.getDate() + 1);
    }
    return d;
  }

  getTitle(startHours: number): string {
    return this.getShiftByHour(startHours).title;
  }

  getColor(startHours: number): string {
    return this.getShiftByHour(startHours).color;
  }

  getAvailableSlots(resourceId: string, dayStart: Date): Record<string, boolean> {
    const dayEnd = new Date(dayStart);
    dayEnd.setDate(dayEnd.getDate() + 1);
    dayEnd.setHours(6, 0, 0, 0);
    const dayEvents = this.myEvents.filter((e: MbscCalendarEvent) => {
      const eStart = new Date(e.start as Date);
      return eStart >= dayStart && eStart < dayEnd;
    });
    const slots: Record<string, boolean> = { morning: true, afternoon: true, night: true };
    dayEvents.forEach((e: MbscCalendarEvent) => {
      if (e.resource === resourceId) {
        slots['morning'] = slots['afternoon'] = slots['night'] = false;
      } else {
        const startHour = new Date(e.start as Date).getHours();
        if (startHour === 6) {
          slots['morning'] = false;
        } else if (startHour === 14) {
          slots['afternoon'] = false;
        } else if (startHour === 22) {
          slots['night'] = false;
        }
      }
    });
    return slots;
  }

  clearColorsForResource(colors: MbscCalendarColor[], resourceId: string, date: Date): MbscCalendarColor[] {
    const base = new Date(date);
    base.setHours(0, 0, 0, 0);
    const baseTime = +base;
    return colors.filter((c) => {
      if (c.resource === resourceId) {
        const cDate = new Date(c.start as Date);
        cDate.setHours(0, 0, 0, 0);
        if (+cDate === baseTime) {
          const hour = new Date(c.start as Date).getHours();
          if (hour === 6 || hour === 14 || hour === 22) {
            return false;
          }
        }
      }
      return true;
    });
  }

  myOptions: MbscEventcalendarOptions = {
    cssClass: 'mds-24-hour-manufacturing-calendar',
    dragToMove: true,
    dragToCreate: false,
    clickToCreate: 'single',
    dragToResize: false,
    dragTimeStep: 480,
    dragTimeStepBase: 'viewStart',
    eventDelete: true,
    eventOverlap: false,
    extendDefaultEvent: (args: MbscNewEventData): MbscCalendarEvent => {
      const start = new Date(args.start);
      const newStart = this.calculateStart(start);
      const newEnd = this.calculateEnd(start);
      const title = this.getTitle(newStart.getHours());
      const color = this.getColor(newStart.getHours());
      return {
        title: title,
        start: newStart,
        end: newEnd,
        color: color,
      };
    },
    onCellHoverIn: (args: MbscCellHoverEvent) => {
      const hoveredDate = new Date(args.date);
      const shift = this.getShiftByHour(hoveredDate.getHours());
      if (!shift) {
        return;
      }

      const dayStart = new Date(args.date);
      dayStart.setHours(6, 0, 0, 0);
      const availableSlots = this.getAvailableSlots(String(args.resource.id), dayStart);
      const slotKey = Object.keys(availableSlots).find((key) => availableSlots[key] && this.shifts[key].startHour === shift.startHour);
      if (slotKey) {
        const startTime = new Date(dayStart);
        startTime.setHours(this.shifts[slotKey].startHour, 0, 0, 0);
        const endTime = new Date(+startTime + 8 * 60 * 60 * 1000 - 1);
        this.availableSlotOnHover = {
          background: '#c1ffe180',
          cssClass: 'available-slot mbsc-font-icon mbsc-icon-plus',
          start: (+startTime + 1) as any,
          end: endTime,
          resource: String(args.resource.id),
        };
        this.colors = [...this.colors, this.availableSlotOnHover!];
      }
    },
    onCellHoverOut: () => {
      if (this.availableSlotOnHover) {
        this.colors = this.colors.filter((c) => c !== this.availableSlotOnHover);
        this.availableSlotOnHover = null;
      }
    },
    onEventCreate: (args: MbscEventCreateEvent) => {
      const event = args.event as MbscCalendarEvent;
      const dayStart = new Date(event.start as Date);
      dayStart.setHours(6, 0, 0, 0);
      const dayEnd = new Date(dayStart);
      dayEnd.setDate(dayEnd.getDate() + 1);
      dayEnd.setHours(6, 0, 0, 0);
      const dayEvents = this.myEvents.filter((e: MbscCalendarEvent) => {
        const eStart = new Date(e.start as Date);
        return eStart >= dayStart && eStart < dayEnd;
      });
      const conflict = dayEvents.find(
        (ev: MbscCalendarEvent) => ev.resource === event.resource || +new Date(ev.start as Date) === +new Date(event.start as Date),
      );
      if (conflict) {
        this.notify.toast({ message: 'Already assigned' });
        return false;
      }
      this.colors = this.clearColorsForResource(this.colors, event.resource as string, event.start as Date);
      const day = new Date(event.start as Date);
      day.setHours(0, 0, 0, 0);
      this.redResources = { ...this.redResources, [String(event.resource) + day.toISOString()]: false };
      this.availableSlotOnHover = null;
      this.myEvents = [...this.myEvents, { ...event, id: Date.now() }];
      return;
    },
    onEventClick: () => {
      this.notify.toast({
        message: 'Already assigned',
      });
    },
    onEventDelete: (args: MbscEventDeleteEvent) => {
      this.myEvents = this.myEvents.filter((e) => e.id !== args.event.id);
      const colorStart = new Date(args.event.start as Date);
      colorStart.setHours(6, 0, 0, 0);
      const colorEnd = new Date(args.event.start as Date);
      colorEnd.setDate(colorEnd.getDate() + 1);
      colorEnd.setHours(6, 0, 0, 0);

      this.colors = [
        ...this.colors,
        { start: colorStart, end: colorEnd, background: '#ffdbd280', resource: args.event.resource as string },
      ];
      const resource = args.event.resource as string;
      const day = new Date(args.event.start as Date);
      day.setHours(0, 0, 0, 0);
      this.redResources = { ...this.redResources, [resource + day.toISOString()]: true };
      this.notify.toast({
        message: (args.event.title as string) + ' deleted',
      });
    },
    onEventDragStart: (args: MbscEventDragEvent) => {
      this.draggedEventStart = args.event.start as Date | null;
      this.draggedEventEnd = args.event.end as Date | null;
      this.draggedEventResource = args.event.resource as string | null;
    },
    onEventUpdateFailed: () => {
      this.notify.toast({
        message: 'Already assigned',
      });
    },
    onEventUpdate: (args: MbscEventUpdateEvent) => {
      const event = args.event;
      const dayStart = new Date(event.start as Date);
      dayStart.setHours(6, 0, 0, 0);
      const dragStartDay = new Date(this.draggedEventStart!);
      dragStartDay.setHours(6, 0, 0, 0);
      if (+dayStart !== +dragStartDay) {
        return false;
      }

      const dayEnd = new Date(dayStart);
      dayEnd.setDate(dayEnd.getDate() + 1);
      dayEnd.setHours(6, 0, 0, 0);

      const dayEvents = this.myEvents.filter((e: MbscCalendarEvent) => {
        const eStart = new Date(e.start as Date);
        return eStart >= dayStart && eStart < dayEnd;
      });

      const conflicts = dayEvents.filter(
        (ev: MbscCalendarEvent) =>
          ev.id !== event.id && (ev.resource === event.resource || +new Date(ev.start as Date) === +new Date(event.start as Date)),
      );
      const evStart = new Date(event.start as Date);
      event.title = this.getTitle(evStart.getHours());
      event.color = this.getColor(evStart.getHours());

      if (conflicts.length) {
        let shouldReturn = false;
        conflicts.forEach((sh: MbscCalendarEvent, i: number) => {
          if (sh.resource === event.resource) {
            if (event.start && +event.start === +new Date(sh.start as Date)) {
              shouldReturn = true;
            }
            conflicts[i] = { ...sh, resource: this.draggedEventResource! };
          } else {
            conflicts[i] = {
              ...sh,
              start: this.draggedEventStart!,
              end: this.draggedEventEnd!,
              title: this.getTitle(new Date(this.draggedEventStart!).getHours()),
              color: this.getColor(new Date(this.draggedEventStart!).getHours()),
            };
          }
        });
        if (shouldReturn) {
          return false;
        }
        const toUpdate = [...conflicts, event];
        this.myEvents = this.myEvents.map((e) => {
          const u = toUpdate.find((u) => u.id === e.id);
          return u || e;
        });
      } else {
        if (dayEvents.some((ev) => ev.id === event.id)) {
          this.myEvents = this.myEvents.map((e) => (e.id === event.id ? event : e));
        }
      }

      this.colors = this.clearColorsForResource(this.colors, event.resource as string, event.start as Date);
      const day = new Date(event.start as Date);
      day.setHours(0, 0, 0, 0);
      this.redResources = { ...this.redResources, [String(event.resource) + day.toISOString()]: false };
      if (!conflicts.length && event.resource !== this.draggedEventResource) {
        const colorEnd = new Date(dayStart);
        colorEnd.setDate(colorEnd.getDate() + 1);
        colorEnd.setHours(6, 0, 0, 0);
        this.colors = [
          ...this.colors,
          { start: dayStart, end: colorEnd, background: '#ffdbd280', resource: args.oldEvent!.resource as string },
        ];
        this.redResources = { ...this.redResources, [(args.oldEvent!.resource as string) + day.toISOString()]: true };
      }
      this.draggedEventStart = null;
      this.draggedEventEnd = null;
      this.draggedEventResource = null;
      return;
    },
    groupBy: 'date',
    resources: this.myResources,
    view: {
      scheduler: {
        type: 'week',
        allDay: false,
        startTime: '06:00',
        endTime: '06:00+1',
        timeCellStep: 120,
        timeLabelStep: 120,
      },
    },
  };

  getResourceStyle(resource: MbscResource, day: Date): Record<string, string> {
    return day && this.redResources[resource.id + day.toISOString()]
      ? { color: '#53000080', background: '#ffdbd280', margin: '-0.5em', padding: '0.5em' }
      : {};
  }
}

Duplicate Session

You have signed in on a different machine. To keep everything in sync, you have been signed out of this session.

If you need to invite team members, manage seats and accounts you can do it from the licenses page.

Duplicate Session

We noticed you are logged in on a different machine and have closed your old session.

If you need to invite team members, manage seats and accounts you can do it from the licenses page.