---
title: "React Timeline Shifts and Tasks Example | Mobiscroll"
component: "Timeline"
framework: "React"
library_version: "6.1.5"
url: "https://demo.mobiscroll.com/react/timeline/tasks-subtasks-under-shifts"
---

# Shifts and Tasks - React Timeline demo

Display shifts and linked tasks on a React Timeline — group tasks under parent shifts and restrict movement to each shift's time range. For React and Next.JS.

- [Live demo](https://demo.mobiscroll.com/react/timeline/tasks-subtasks-under-shifts)
- [View React JS code on GitHub](https://github.com/acidb/mobiscroll-demos-react/tree/main/src/demos/eventcalendar/timeline/tasks-subtasks-under-shifts)
- [View React TS code on GitHub](https://github.com/acidb/mobiscroll-demos-react-ts/tree/main/src/demos/eventcalendar/timeline/tasks-subtasks-under-shifts)
- This demo in other frameworks: [JavaScript](https://demo.mobiscroll.com/javascript/timeline/tasks-subtasks-under-shifts.md), [jQuery](https://demo.mobiscroll.com/jquery/timeline/tasks-subtasks-under-shifts.md), [Angular](https://demo.mobiscroll.com/angular/timeline/tasks-subtasks-under-shifts.md), [Vue](https://demo.mobiscroll.com/vue/timeline/tasks-subtasks-under-shifts.md)

## Description

Visualize shifts and tasks within them using the timeline view. With the help of the [`order`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-data) property of the data object you can always guarantee that shifts are always displayed at the top (alternatively below tasks) and tasks within those shifts are rendered below.

The shift and task events are bind together with custom data properties. Every shift contains the task IDs in the `tasks` property, and every task has a `shift` property which indicates the parent shift. These properties are used later in the validation logic to handle the two distinct event type behaviours. The validation is implemented by dynamically setting [invalid ranges](https://mobiscroll.com/docs/react/eventcalendar/api#opt-invalid) through the [`onEventCreated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreated), [`onEventUpdated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventUpdated) and [`onEventDeleted`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDeleted) lifecycle events.

**Do you want to learn about the event ordering?** [Learn more about it in the documentation →](https://mobiscroll.com/docs/react/eventcalendar/timeline#event-order)

## What this demo shows

- A desktop weekly timeline for planning shifts and tasks together, with Monday to Friday workdays arranged horizontally and employee resources listed vertically on the left.
- **Header navigation** The month and year label opens date navigation, while the previous and next arrows and the Today button move between weeks or return to the current day.
- **Week view** The fixed date strip shows the selected work week from Monday to Friday using the `DD DDD MMM YYYY` date format, with the current date highlighted.
- **Time grid** The timeline displays working hours from 5 AM to 7 PM with hourly columns under each day.
- **Resources** The left side lists five employee resources.
- **Current time** A vertical blue line and time label mark the current time.
- **Hover behavior** Hovering over the time grid shows a time indicator that follows the cursor in 15-minute increments.
- **Shift labels** Shifts are rendered as gray labels above the task events and can span an employee's working hours, including shift types such as Daily Shift and Flex Shift.
- **Shift constraints** Shift labels can be moved in time, but they cannot be moved between resources or overlap with other shifts.
- **Grouped tasks** Task events are grouped under their parent shifts. When a shift is updated, the tasks assigned to that shift move with it.
- **Task constraints** Task events can be moved in time only within their parent shift period.
- **Event cards** Task events are rendered as colored cards with a colored stripe on the left, a bold task name, a calculated duration next to the title, and the exact start and end time below the title.
- **Event creation** New events can be created by double-clicking an individual resource row or by clicking and dragging across a time range.
- **Shift-first creation** Empty time ranges create a shift first; tasks can then be created and assigned to that shift.
- **Event interaction** Events are highlighted on hover and show drag and resize handles for repositioning or changing duration.
- **Event selection** Clicking an event selects and highlights it.
- **Horizontal scrolling** Horizontal scrolling moves across multiple days while keeping employee rows visible.

## Best for

- **Workforce scheduling** Planning employee shifts and the tasks assigned within each shift.
- **Shift management** Coordinating daily and flexible shifts while keeping work items tied to the correct employee and time range.
- **Service operations** Scheduling field, support, or operations work where shift coverage and task timing need to be managed together.
- **IT support planning** Assigning support tasks within employee availability windows.
- **Maintenance teams** Organizing maintenance work by employee, workday, shift, and task duration.
- **Employee workload management** Reviewing how individual work items fit into each employee's scheduled working hours.

## API used in this demo

- [`theme`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-theme) - option
- [`themeVariant`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-themeVariant) - option
- [`cssClass`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-cssClass) - option
- [`getEvents`](https://mobiscroll.com/docs/react/eventcalendar/api#method-getEvents) - method
- [`dragToCreate`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToCreate) - option
- [`dragToMove`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToMove) - option
- [`dragToResize`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-dragToResize) - option
- [`view`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-view) - option
- [`data`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-data) - option
- [`invalid`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-invalid) - option
- [`extendDefaultEvent`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-extendDefaultEvent) - option
- [`onEventCreated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventCreated) - event
- [`onEventDeleted`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDeleted) - event
- [`onEventUpdated`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventUpdated) - event
- [`onEventDragStart`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDragStart) - event
- [`onEventDragEnd`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventDragEnd) - event
- [`onEventUpdateFailed`](https://mobiscroll.com/docs/react/eventcalendar/api#event-onEventUpdateFailed) - event
- [`resources`](https://mobiscroll.com/docs/react/eventcalendar/api#opt-resources) - option
- [`renderTimelineEventContent`](https://mobiscroll.com/docs/react/eventcalendar/api#renderer-renderTimelineEventContent) - renderer
- [`onClose`](https://mobiscroll.com/docs/react/notifications/toast#event-onClose) - event

## Code

### JSX

```jsx
import { Eventcalendar, setOptions, Toast } from '@mobiscroll/react';
import { useCallback, useMemo, useRef, useState } from 'react';

setOptions({
  theme: 'ios',
  themeVariant: 'light'
});

const blockedOutTimes = [
  {
    start: '00:00',
    end: '05:00',
    recurring: {
      repeat: 'daily',
    },
    workOff: true,
  },
  {
    start: '19:00',
    end: '00:00',
    recurring: {
      repeat: 'daily',
    },
    workOff: true,
  },
];

function App() {
  const timelineInst = useRef();
  const [myInvalids, setInvalid] = useState(blockedOutTimes);
  const [isToastOpen, setToastOpen] = useState(false);
  const [toastMessage, setToastMessage] = useState('');
  const myResources = useMemo(
    () => [
      {
        id: 1,
        color: '#ff0101',
        name: 'Emma Smith',
        eventDragBetweenResources: false,
      },
      {
        id: 2,
        color: '#239a21',
        name: 'James Brown',
        eventDragBetweenResources: false,
      },
      {
        id: 3,
        color: '#8f1ed6',
        name: 'Olivia Miller',
        eventDragBetweenResources: false,
      },
      {
        id: 4,
        color: '#01adff',
        name: 'Robert Taylor',
        eventDragBetweenResources: false,
      },

      {
        id: 5,
        color: '#F58585',
        name: 'John Doe',
        eventDragBetweenResources: false,
      },
    ],
    [],
  );

  const [myEvents, setEvents] = useState([
    {
      id: 1,
      start: '2026-10-24T05:00',
      end: '2026-10-24T12:00',
      title: 'Daily Shift',
      resource: 1,
      tasks: ['es-1', 'es-2', 'es-3'],
      order: 1,
      color: '#513737',
      cssClass: 'mds-task-shift',
    },
    {
      id: 'es-1',
      start: '2026-10-24T05:00',
      end: '2026-10-24T08:30',
      title: 'Server Maintenance',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
    {
      id: 'es-2',
      start: '2026-10-24T09:00',
      end: '2026-10-24T10:45',
      title: 'Monitor System Performance',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
    {
      id: 'es-3',
      start: '2026-10-24T11:00',
      end: '2026-10-24T12:00',
      title: 'Backup and Recovery',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
  ]);

  const myView = useMemo(
    () => ({
      timeline: {
        type: 'week',
        startDay: 1,
        startTime: '05:00',
        endTime: '19:00',
        endDay: 5,
        eventHeight: 'variable',
      },
    }),
    [],
  );

  const myDefaultEvent = useCallback((args) => {
    if (timelineInst.current) {
      const events = timelineInst.current
        .getEvents(args.start, new Date(+args.start + 3600000))
        .filter((e) => e.resource === args.resource);
      const isShift = events.length === 0;
      return {
        order: isShift ? 1 : 2,
        cssClass: isShift ? 'mds-task-shift' : 'mds-task-subtask',
        color: isShift ? '#513737' : '',
        title: isShift ? 'New Shift' : 'New Task',
        tasks: isShift ? [] : undefined,
        shift: isShift ? undefined : events[0].id,
      };
    }
  }, []);

  const handelEventCreate = useCallback(
    (args, inst) => {
      const event = args.event;
      const overlapEvents = inst.getEvents(event.start, event.end).filter((e) => e.resource === event.resource);

      if (event.shift) {
        // Tasks was created
        const shift = { ...overlapEvents[0] };
        const shiftIndex = myEvents.findIndex((x) => x.id === event.shift);
        const newEventList = [...myEvents];

        if (overlapEvents.length > 2) {
          // Prevent overlap
          setEvents(newEventList);
          setToastMessage('No space for task');
          setToastOpen(true);
        } else {
          // Update the shift
          shift.tasks.push(event.id);
          newEventList.splice(shiftIndex, 1, shift);

          // Update subtask
          event.start = new Date(Math.max(+new Date(shift.start), +event.start));
          event.end = new Date(Math.min(+new Date(shift.end), +event.end));
          event.shift = shift.id;
          newEventList.push(event);
          setEvents(newEventList);
        }
      } else {
        setEvents([...myEvents, event]);
      }
    },
    [myEvents],
  );

  const handelEventDelete = useCallback(
    (args) => {
      let newEventList = [...myEvents];
      const event = args.event;
      const evIndex = myEvents.findIndex((x) => x.id === event.id);
      newEventList.splice(evIndex, 1);

      if (event.tasks) {
        // Update shift tasks
        newEventList = newEventList.filter((ev) => !event.tasks.includes(ev.id));
      }

      if (event.shift) {
        const shift = newEventList.find((ev) => ev.resource === event.resource && ev.id === event.shift);

        // Remove the deleted task id from the shift data
        shift.tasks = shift.tasks.filter((t) => t !== event.id);
      }

      setEvents(newEventList);
    },
    [myEvents],
  );

  const handelEventDragStart = useCallback((args, inst) => {
    const events = inst.getEvents();
    const event = args.event;
    const tempInvalid = [];

    if (event.tasks) {
      // Shift
      const shiftsInResource = events.filter((e) => e.tasks !== undefined && e.resource === event.resource && e.id !== event.id);

      shiftsInResource.forEach((e) => {
        tempInvalid.push({
          start: e.start,
          end: e.end,
          resource: e.resource,
          cssClass: 'mds-task-blocked',
          shift: true,
        });
      });

      setInvalid([...blockedOutTimes, ...tempInvalid]);
    } else {
      // Subtask
      const shift = events.find((ev) => ev.resource === event.resource && ev.id === event.shift);
      tempInvalid.push(
        {
          start: new Date(+new Date(shift.start) - 7 * 86400000),
          end: shift.start,
          resource: shift.resource,
          cssClass: 'mds-task-blocked',
          task: true,
        },
        {
          start: shift.end,
          end: new Date(+new Date(shift.end) + 7 * 86400000),
          resource: shift.resource,
          cssClass: 'mds-task-blocked',
          task: true,
        },
      );
      setInvalid([...blockedOutTimes, ...tempInvalid]);
    }
  }, []);

  const handelEventDragEnd = useCallback(() => {
    setInvalid(blockedOutTimes);
  }, []);

  const handelEventUpdate = useCallback(
    (args, inst) => {
      const newEventList = [...myEvents];
      const event = args.event;
      const evIndex = newEventList.findIndex((x) => x.id === event.id);
      const oldEvent = args.oldEvent;

      if (event.tasks) {
        // Shift was updated
        let shiftStart = new Date(event.start);
        let shiftEnd = new Date(event.end);
        let subTasksDuration = 0;
        const startDiff = +shiftStart - +new Date(oldEvent.start);
        const endDiff = +shiftEnd - +new Date(oldEvent.end);
        const startResize = startDiff > 0;
        const endResize = endDiff < 0;
        const isMove = startDiff === endDiff;
        const isResize = !isMove && (startResize || endResize);

        const tasks = event.tasks.map((el) => {
          const t = newEventList.find((e) => e.id === el);
          subTasksDuration += +new Date(t.end) - +new Date(t.start);
          return t;
        });

        if (isResize && +shiftEnd - +shiftStart < subTasksDuration) {
          // Limit the shift to don't be smaller than the containing subtasks
          shiftStart = endResize ? shiftStart : new Date(+shiftEnd - subTasksDuration);
          shiftEnd = startResize ? shiftEnd : new Date(+shiftStart + subTasksDuration);
          event.start = shiftStart;
          event.end = shiftEnd;
          newEventList.splice(evIndex, 1, event);
          setEvents(newEventList);
        }

        // Resize or move
        if (isResize || startDiff === endDiff) {
          // Update subtask
          tasks.forEach((task, i) => {
            const taskStart = new Date(task.start);
            const taskEnd = new Date(task.end);
            if (isResize) {
              task.start = new Date(i === 0 ? +shiftStart : +new Date(tasks[i - 1].end));
              task.end = new Date(Math.min(+task.start + (+taskEnd - +taskStart), +new Date(event.end)));
            } else {
              task.start = new Date(+taskStart + startDiff);
              task.end = new Date(+taskEnd + startDiff);
            }

            const taskIndex = newEventList.findIndex((x) => x.id === task.id);
            newEventList.splice(taskIndex, 1, task);
          });

          setEvents(newEventList);
        }
      } else {
        // Subtask was updated
        const eventOverlap = inst.getEvents(event.start, event.end).filter((e) => e.resource === event.resource);
        if (eventOverlap.length > 2) {
          // Don't let subtask to overlap
          newEventList.splice(evIndex, 1, oldEvent);
          setEvents(newEventList);
          setToastMessage('No space for task');
          setToastOpen(true);
        }
      }
    },
    [myEvents],
  );

  const handleToastClose = useCallback(() => {
    setToastOpen(false);
  }, []);

  const handelEventUpdateFailed = useCallback((args) => {
    const invalid = args.invalid;
    let msg = '';
    if (invalid.workOff) {
      msg = 'Shift falls out of working hours';
    } else if (invalid.task) {
      msg = 'Task falls out of shift';
    } else if (invalid.shift) {
      msg = 'Shifts cannot overlap';
    }

    if (msg) {
      setToastMessage(msg);
      setToastOpen(true);
    }
  }, []);

  const myEvent = useCallback((args) => {
    const duration = (+args.endDate - +args.startDate) / 3600000;
    return (
      <>
        {args.title}
        <span className="mds-task-hours"> - {duration + (duration === 1 ? 'h' : 'hrs')}</span>
      </>
    );
  }, []);

  return (
    <>
      <Eventcalendar
        dragToCreate={true}
        dragToMove={true}
        dragToResize={true}
        ref={timelineInst}
        view={myView}
        data={myEvents}
        invalid={myInvalids}
        extendDefaultEvent={myDefaultEvent}
        onEventCreated={handelEventCreate}
        onEventDeleted={handelEventDelete}
        onEventUpdated={handelEventUpdate}
        onEventDragStart={handelEventDragStart}
        onEventDragEnd={handelEventDragEnd}
        onEventUpdateFailed={handelEventUpdateFailed}
        resources={myResources}
        renderTimelineEventContent={myEvent}
      />
      <Toast isOpen={isToastOpen} message={toastMessage} onClose={handleToastClose} />
    </>
  );
}

export default App;
```

### TSX

```tsx
import {
  Eventcalendar,
  MbscCalendarEvent,
  MbscCalendarEventData,
  MbscEventcalendarView,
  MbscEventCreatedEvent,
  MbscEventDeletedEvent,
  MbscEventDragEvent,
  MbscEventUpdateEvent,
  MbscEventUpdateFailedEvent,
  MbscNewEventData,
  MbscResource,
  setOptions,
  Toast,
} from '@mobiscroll/react';
import { FC, useCallback, useMemo, useRef, useState } from 'react';

interface MyEvent extends MbscCalendarEvent {
  shift?: number | string;
  tasks?: Array<string | number>;
}

interface MyBlockedTimes extends MbscCalendarEvent {
  shift?: boolean;
  task?: boolean;
  workOff?: boolean;
}

setOptions({
  theme: 'ios',
  themeVariant: 'light'
});

const blockedOutTimes: MyBlockedTimes[] = [
  {
    id: 1,
    start: '00:00',
    end: '05:00',
    recurring: {
      repeat: 'daily',
    },
    workOff: true,
  },
  {
    id: 2,
    start: '19:00',
    end: '00:00',
    recurring: {
      repeat: 'daily',
    },
    workOff: true,
  },
];

const App: FC = () => {
  const timelineInst = useRef<Eventcalendar>(null);
  const [myInvalids, setInvalid] = useState(blockedOutTimes);
  const [isToastOpen, setToastOpen] = useState(false);
  const [toastMessage, setToastMessage] = useState('');

  const [myEvents, setEvents] = useState<MyEvent[]>([
    {
      id: 1,
      start: '2026-10-24T05:00',
      end: '2026-10-24T12:00',
      title: 'Daily Shift',
      resource: 1,
      tasks: ['es-1', 'es-2', 'es-3'],
      order: 1,
      color: '#513737',
      cssClass: 'mds-task-shift',
    },
    {
      id: 'es-1',
      start: '2026-10-24T05:00',
      end: '2026-10-24T08:30',
      title: 'Server Maintenance',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
    {
      id: 'es-2',
      start: '2026-10-24T09:00',
      end: '2026-10-24T10:45',
      title: 'Monitor System Performance',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
    {
      id: 'es-3',
      start: '2026-10-24T11:00',
      end: '2026-10-24T12:00',
      title: 'Backup and Recovery',
      resource: 1,
      shift: 1,
      order: 2,
      cssClass: 'mds-task-subtask',
    },
  ]);

  const myResources = useMemo(
    () => [
      {
        id: 1,
        color: '#ff0101',
        name: 'Emma Smith',
        eventDragBetweenResources: false,
      },
      {
        id: 2,
        color: '#239a21',
        name: 'James Brown',
        eventDragBetweenResources: false,
      },
      {
        id: 3,
        color: '#8f1ed6',
        name: 'Olivia Miller',
        eventDragBetweenResources: false,
      },
      {
        id: 4,
        color: '#01adff',
        name: 'Robert Taylor',
        eventDragBetweenResources: false,
      },

      {
        id: 5,
        color: '#F58585',
        name: 'John Doe',
        eventDragBetweenResources: false,
      },
    ],
    [],
  );

  const myView = useMemo<MbscEventcalendarView>(
    () => ({
      timeline: {
        type: 'week',
        startDay: 1,
        startTime: '05:00',
        endTime: '19:00',
        endDay: 5,
        eventHeight: 'variable',
      },
    }),
    [],
  );

  const myDefaultEvent = useCallback((args: MbscNewEventData): MbscCalendarEvent => {
    const events = timelineInst.current
      ? timelineInst.current.getEvents(args.start, new Date(+args.start + 3600000)).filter((e) => e.resource === args.resource)
      : [];
    const isShift = events.length === 0;
    return {
      order: isShift ? 1 : 2,
      cssClass: isShift ? 'mds-task-shift' : 'mds-task-subtask',
      color: isShift ? '#513737' : '',
      title: isShift ? 'New Shift' : 'New Task',
      tasks: isShift ? [] : undefined,
      shift: isShift ? undefined : events[0].id,
    };
  }, []);

  const handelEventCreate = useCallback(
    (args: MbscEventCreatedEvent) => {
      const inst = args.inst!;
      const event = args.event;
      const overlapEvents = inst.getEvents(event.start, event.end).filter((e) => e.resource === event.resource);

      if (event.shift) {
        // Tasks was created
        const shift = { ...overlapEvents[0] };
        const shiftIndex = myEvents.findIndex((x) => x.id === event.shift);
        const newEventList = [...myEvents];

        if (overlapEvents.length > 2) {
          // Prevent overlap
          setEvents(newEventList);
          setToastMessage('No space for task');
          setToastOpen(true);
        } else {
          // Update the shift
          shift.tasks.push(event.id);
          newEventList.splice(shiftIndex, 1, shift);

          // Update subtask
          event.start = new Date(Math.max(+new Date(shift.start as Date), +event.start!));
          event.end = new Date(Math.min(+new Date(shift.end as Date), +event.end!));
          event.shift = shift.id;
          newEventList.push(event);
          setEvents(newEventList);
        }
      } else {
        setEvents([...myEvents, event]);
      }
    },
    [myEvents],
  );

  const handelEventDelete = useCallback(
    (args: MbscEventDeletedEvent) => {
      let newEventList = [...myEvents];
      const event = args.event;
      const evIndex = myEvents.findIndex((x) => x.id === event.id);
      newEventList.splice(evIndex, 1);

      if (event.tasks) {
        // Update shift tasks
        newEventList = newEventList.filter((ev) => !event.tasks.includes(ev.id));
      }

      if (event.shift) {
        const shift: MyEvent = newEventList.find((ev) => ev.resource === event.resource && ev.id === event.shift)!;

        // Remove the deleted task id from the shift data
        shift.tasks = shift.tasks && shift.tasks.filter((t) => t !== event.id);
      }

      setEvents(newEventList);
    },
    [myEvents],
  );

  const handelEventDragStart = useCallback((args: MbscEventDragEvent) => {
    const inst = args.inst!;
    const events = inst.getEvents();
    const event = args.event;
    const tempInvalid: MbscResource[] = [];

    if (event.tasks) {
      // Shift
      const shiftsInResource = events.filter((e) => e.tasks !== undefined && e.resource === event.resource && e.id !== event.id);

      shiftsInResource.forEach((e) => {
        tempInvalid.push({
          id: 'mds-s-' + e.id,
          start: e.start,
          end: e.end,
          resource: e.resource,
          cssClass: 'mds-task-blocked',
          shift: true,
        });
      });

      setInvalid([...blockedOutTimes, ...tempInvalid]);
    } else {
      // Subtask
      const shift = events.find((ev) => ev.resource === event.resource && ev.id === event.shift)!;
      tempInvalid.push(
        {
          id: 'mds-te-' + shift.id,
          start: new Date(+new Date(shift.start as Date) - 7 * 86400000),
          end: shift.start,
          resource: shift.resource,
          cssClass: 'mds-task-blocked',
          task: true,
        },
        {
          id: 'mds-tu-' + shift.id,
          start: shift.end,
          end: new Date(+new Date(shift.end as Date) + 7 * 86400000),
          resource: shift.resource,
          cssClass: 'mds-task-blocked',
          task: true,
        },
      );
      setInvalid([...blockedOutTimes, ...tempInvalid]);
    }
  }, []);

  const handelEventDragEnd = useCallback(() => {
    setInvalid(blockedOutTimes);
  }, []);

  const handelEventUpdate = useCallback(
    (args: MbscEventUpdateEvent) => {
      const inst = args.inst!;
      const newEventList = [...myEvents];
      const event: MyEvent = args.event;
      const evIndex = newEventList.findIndex((x) => x.id === event.id);
      const oldEvent = args.oldEvent as MyEvent;

      if (event.tasks) {
        // Shift was updated
        let shiftStart = new Date(event.start as Date);
        let shiftEnd = new Date(event.end as Date);
        let subTasksDuration = 0;
        const startDiff = +shiftStart - +new Date(oldEvent!.start as Date);
        const endDiff = +shiftEnd - +new Date(oldEvent!.end as Date);
        const startResize = startDiff > 0;
        const endResize = endDiff < 0;
        const isMove = startDiff === endDiff;
        const isResize = !isMove && (startResize || endResize);

        const tasks = event.tasks.map((el) => {
          const t: MyEvent = newEventList.find((e) => e.id === el)!;
          subTasksDuration += +new Date(t!.end as Date) - +new Date(t!.start as Date);
          return t;
        });

        if (isResize && +shiftEnd - +shiftStart < subTasksDuration) {
          // Limit the shift to don't be smaller than the containing subtasks
          shiftStart = endResize ? shiftStart : new Date(+shiftEnd - subTasksDuration);
          shiftEnd = startResize ? shiftEnd : new Date(+shiftStart + subTasksDuration);
          event.start = shiftStart;
          event.end = shiftEnd;
          newEventList.splice(evIndex, 1, event);
          setEvents(newEventList);
        }

        // Resize or move
        if (isResize || startDiff === endDiff) {
          // Update subtask
          tasks.forEach((task: MyEvent, i) => {
            const taskStart = new Date(task!.start as Date);
            const taskEnd = new Date(task!.end as Date);
            if (isResize) {
              task.start = new Date(i === 0 ? +shiftStart : +new Date(tasks[i - 1].end as Date));
              task.end = new Date(Math.min(+task.start + (+taskEnd - +taskStart), +new Date(event.end as Date)));
            } else {
              task.start = new Date(+taskStart + startDiff);
              task.end = new Date(+taskEnd + startDiff);
            }

            const taskIndex = newEventList.findIndex((x) => x.id === task.id);
            newEventList.splice(taskIndex, 1, task);
          });

          setEvents(newEventList);
        }
      } else {
        // Subtask was updated
        const eventOverlap = inst.getEvents(event.start, event.end).filter((e) => e.resource === event.resource);
        if (eventOverlap.length > 2) {
          // Don't let subtask to overlap
          newEventList.splice(evIndex, 1, oldEvent);
          setEvents(newEventList);
          setToastMessage('No space for task');
          setToastOpen(true);
        }
      }
    },
    [myEvents],
  );

  const handleToastClose = useCallback(() => {
    setToastOpen(false);
  }, []);

  const handelEventUpdateFailed = useCallback((args: MbscEventUpdateFailedEvent) => {
    const invalid = args.invalid;
    let msg = '';
    if (invalid.workOff) {
      msg = 'Shift falls out of working hours';
    } else if (invalid.task) {
      msg = 'Task falls out of shift';
    } else if (invalid.shift) {
      msg = 'Shifts cannot overlap';
    }

    if (msg) {
      setToastMessage(msg);
      setToastOpen(true);
    }
  }, []);

  const myEvent = useCallback((args: MbscCalendarEventData) => {
    const duration = (+args.endDate - +args.startDate) / 3600000;
    return (
      <>
        {args.title}
        <span className="mds-task-hours"> - {duration + (duration === 1 ? 'h' : 'hrs')}</span>
      </>
    );
  }, []);

  return (
    <>
      <Eventcalendar
        ref={timelineInst}
        dragToCreate={true}
        dragToMove={true}
        dragToResize={true}
        view={myView}
        data={myEvents}
        invalid={myInvalids}
        extendDefaultEvent={myDefaultEvent}
        onEventCreated={handelEventCreate}
        onEventDeleted={handelEventDelete}
        onEventUpdated={handelEventUpdate}
        onEventDragStart={handelEventDragStart}
        onEventDragEnd={handelEventDragEnd}
        onEventUpdateFailed={handelEventUpdateFailed}
        resources={myResources}
        renderTimelineEventContent={myEvent}
      />
      <Toast isOpen={isToastOpen} message={toastMessage} onClose={handleToastClose} />
    </>
  );
};
export default App;
```

### CSS

```css
.mds-task-shift {
  height: 30px;
}

.mds-task-subtask {
  height: 40px;
}

.mds-task-subtask .mbsc-schedule-event-range,
.mds-task-subtask .mbsc-schedule-event-title {
  font-size: 10px;
  line-height: 13px;
}

.mds-task-shift .mbsc-schedule-event-range,
.mds-task-shift .mbsc-schedule-event-title {
  display: inline-block;
  margin: 0 4px;
}

.mds-task-hours {
  font-size: 10px;
  font-weight: 400;
}

.mds-task-blocked.mbsc-schedule-invalid {
  text-align: center;
  align-items: center;
  font-weight: bold;
  background: repeating-linear-gradient(-45deg, #f3f3f3, #f3f3f3 11px, #e5e5e5 11px, #e5e5e5 22px);
}
```

## Implementation notes

- Use `timeline: { type: 'week', startDay: 1, endDay: 5, startTime: '05:00', endTime: '19:00', eventHeight: 'variable' }` - a Mon-Fri week view with a variable row height so shifts and tasks can stack at different heights in the same resource row.
- Define 5 resources: Emma Smith (`#ff0101`), James Brown (`#239a21`), Olivia Miller (`#8f1ed6`), Robert Taylor (`#01adff`), John Doe (`#F58585`). Set `eventDragBetweenResources: false` on every resource - tasks and shifts must stay on their assigned person.
- **Shift/task data model** - every event carries an `order` property to control stacking:
  - **Shifts**: `order: 1`, `cssClass: 'mds-task-shift'`, `color: '#513737'`, and a `tasks` array listing the IDs of their child subtasks (e.g. `tasks: ['es-1', 'es-2', 'es-3']`).
  - **Subtasks**: `order: 2`, `cssClass: 'mds-task-subtask'`, and a `shift` property holding the parent shift's ID (e.g. `shift: 1`). Lower `order` renders first (top), higher `order` renders below - shifts always appear above their tasks.
- **Static invalids** - block 00:00-05:00 and 19:00-00:00 daily with `workOff: true` on each entry, forming the base `myInvalids` list that is always active.
- Enable `dragToCreate: true`, `dragToMove: true`, `dragToResize: true`.
- **`extendDefaultEvent`** - determines whether a new drag-create produces a shift or a subtask. Use `inst.getEvents(args.start, args.start + 1h)` filtered by `args.resource`: if no event overlaps, the slot is empty → create a shift (`order: 1`, `tasks: []`); otherwise create a subtask (`order: 2`, `shift: overlappingEvent.id`).
- **`onEventCreated`** - if the new event has a `shift` property (it is a task):
  - If `overlapEvents.length > 2` (shift + existing task + new task), remove the new event and show a toast: "No space for task".
  - Otherwise: push the new task's ID into `shift.tasks`, clamp the task's start/end within the shift bounds, and persist both updates.
- **`onEventDeleted`** - if the deleted event is a shift, also remove all its child task IDs from the event list. If it is a subtask, remove its ID from the parent shift's `tasks` array.
- **`onEventDragStart`** - build temporary invalid ranges to visually block illegal drop zones during drag:
  - Dragging a **shift**: mark every other shift on the same resource as blocked (class `mds-task-blocked`, custom `shift: true` flag).
  - Dragging a **subtask**: block the 7-day windows before `shift.start` and after `shift.end` on the same resource (class `mds-task-blocked`, custom `task: true` flag).
  - Merge with `blockedOutTimes` and set as the current `invalid` list.
- **`onEventDragEnd`** - reset `invalid` back to `blockedOutTimes` only.
- **`onEventUpdated`** - if the updated event is a **shift**: compute `startDiff` / `endDiff` to detect move vs. resize; propagate the same offset to all child tasks. If the resize would make the shift smaller than the combined subtask durations, clamp the shift edge. If the updated event is a **subtask**: reject the update (revert to `oldEvent`) and show a toast if it would overlap another event on the same resource.
- **`onEventUpdateFailed`** - inspect `args.invalid` for the three custom flags to show a contextual toast:
  - `workOff` → "Shift falls out of working hours"
  - `task` → "Task falls out of shift"
  - `shift` → "Shifts cannot overlap"
- **`renderTimelineEventContent`** - render the event title plus a duration badge for subtasks: `"<title> - <N>h"` / `"<title> - <N>hrs"`. Subtask events have a smaller, lighter-weight duration label rendered after the title.
- Angular: define a `MyEvent` interface extending `MbscCalendarEvent` with `shift?: number | string` and `tasks?: (string | number)[]`. Inject `Notifications` for toasts. Use `@ViewChild('timeline') timelineInst` to access the instance. Pass event-handler methods in `calendarOptions` instead of inline template bindings.
- Vue: use `MbscToast` component with `isOpen` / `message` reactive refs for toast feedback.
- JavaScript/jQuery: use `inst.removeEvent()` and `inst.updateEvent()` for imperative event mutations; use `inst.setOptions({ invalid: ... })` to swap the invalid list on drag.

## Run it in your project

1. If not signed up yet, or don't own a license, you can [start a free trial](https://mobiscroll.com/starttrial).

2. Clone the repository:

   ```
   git clone https://github.com/acidb/mobiscroll-demos-react.git
   ```

3. If not installed yet, install the Mobiscroll CLI from NPM:

   ```
   npm install -g @mobiscroll/cli
   ```

4. Config Mobiscroll - open a terminal window in the root directory and run:

   ```
   mobiscroll config react
   ```

5. Install dependencies - open a terminal window in the root directory and run:

   ```
   npm install
   ```

6. Run the project:

   ```
   npm run dev
   ```

On a trial, add `--trial` to the config command: `mobiscroll config react --trial`.

Running a demo locally requires a Mobiscroll trial or a license; setup is per framework at https://mobiscroll.com/docs/react/getting-started/installation

## Related demos

- Previous: [Task Progress Tracking](https://demo.mobiscroll.com/react/timeline/show-task-progress-on-event.md) - Show task completion progress on React Timeline events with an inline hover slider for live updates and a progress field in the add/edit dialog.
- Next: [Resource Search and Filtering](https://demo.mobiscroll.com/react/timeline/resource-filtering-search.md) - Add live search and filter to a React Timeline resource header — multi-select by status or group, with a custom empty state and reset.
