From 76914a778b3d679653bf604f5ef8b50dbe72e37c Mon Sep 17 00:00:00 2001
From: Kushal Pandya <kushal@gitlab.com>
Date: Fri, 18 Jan 2019 21:14:01 +0530
Subject: [PATCH] Add support for auto-expanding Roadmap timeline on horizontal
 scroll

Makes Roadmap timeline dynamic to be always
scrollable on any screen size.
Fetches new epics as user scrolls into past or future and renders it.
---
 .../javascripts/roadmap/components/app.vue    |  70 ++++
 .../roadmap/components/epic_item.vue          |  31 +-
 .../roadmap/components/epic_item_timeline.vue |  61 +---
 .../roadmap/components/epics_list_empty.vue   |   6 +-
 .../roadmap/components/epics_list_section.vue |  20 +-
 .../preset_quarters/quarters_header_item.vue  |   8 +-
 .../quarters_header_sub_item.vue              |  12 +-
 .../preset_weeks/weeks_header_item.vue        |  30 +-
 .../preset_weeks/weeks_header_sub_item.vue    |  36 +-
 .../roadmap/components/roadmap_shell.vue      |  26 +-
 .../components/timeline_today_indicator.vue   |   6 +-
 .../assets/javascripts/roadmap/constants.js   |  43 +--
 ee/app/assets/javascripts/roadmap/index.js    |  28 +-
 .../roadmap/mixins/months_preset_mixin.js     |  24 +-
 .../roadmap/mixins/quarters_preset_mixin.js   |  20 +-
 .../roadmap/mixins/weeks_preset_mixin.js      |  33 +-
 .../roadmap/service/roadmap_service.js        |  23 +-
 .../roadmap/store/roadmap_store.js            | 182 +++++++---
 .../roadmap/utils/roadmap_utils.js            | 318 +++++++++++++++---
 ee/app/assets/stylesheets/pages/roadmap.scss  |  81 ++---
 .../roadmap/components/app_spec.js            | 212 +++++++++++-
 .../roadmap/components/epic_item_spec.js      |  27 +-
 .../components/epic_item_timeline_spec.js     | 115 ++-----
 .../components/epics_list_empty_spec.js       |  32 +-
 .../components/epics_list_section_spec.js     |  30 +-
 .../preset_months/months_header_item_spec.js  |  10 +-
 .../months_header_sub_item_spec.js            |   5 +-
 .../quarters_header_item_spec.js              |  24 +-
 .../quarters_header_sub_item_spec.js          |  23 +-
 .../preset_weeks/weeks_header_item_spec.js    |  34 +-
 .../weeks_header_sub_item_spec.js             |  23 +-
 .../roadmap/components/roadmap_shell_spec.js  |  30 +-
 .../roadmap_timeline_section_spec.js          |   5 +-
 .../timeline_today_indicator_spec.js          |  16 +-
 .../mixins/months_preset_mixin_spec.js        |  21 +-
 .../mixins/quarters_preset_mixin_spec.js      |  19 +-
 .../roadmap/mixins/section_mixin_spec.js      |  12 +-
 .../roadmap/mixins/weeks_preset_mixin_spec.js |  33 +-
 ee/spec/javascripts/roadmap/mock_data.js      | 101 +++++-
 .../roadmap/service/roadmap_service_spec.js   |  39 ++-
 .../roadmap/store/roadmap_store_spec.js       | 150 ++++++++-
 .../roadmap/utils/roadmap_utils_spec.js       | 266 +++++++++++++--
 locale/gitlab.pot                             |  16 +-
 43 files changed, 1705 insertions(+), 596 deletions(-)

diff --git a/ee/app/assets/javascripts/roadmap/components/app.vue b/ee/app/assets/javascripts/roadmap/components/app.vue
index 4dd6338338d7..2da718a0b1ab 100644
--- a/ee/app/assets/javascripts/roadmap/components/app.vue
+++ b/ee/app/assets/javascripts/roadmap/components/app.vue
@@ -6,6 +6,9 @@ import { s__ } from '~/locale';
 import { GlLoadingIcon } from '@gitlab/ui';
 import epicsListEmpty from './epics_list_empty.vue';
 import roadmapShell from './roadmap_shell.vue';
+import eventHub from '../event_hub';
+
+import { EXTEND_AS } from '../constants';
 
 export default {
   components: {
@@ -96,6 +99,28 @@ export default {
           Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
         });
     },
+    fetchEpicsForTimeframe({ timeframe, roadmapTimelineEl, extendType }) {
+      this.hasError = false;
+      this.service
+        .getEpicsForTimeframe(this.presetType, timeframe)
+        .then(res => res.data)
+        .then(epics => {
+          if (epics.length) {
+            this.store.addEpics(epics);
+            this.$nextTick(() => {
+              // Re-render timeline bars with updated timeline
+              eventHub.$emit('refreshTimeline', {
+                height: window.innerHeight - roadmapTimelineEl.offsetTop,
+                todayBarReady: extendType === EXTEND_AS.PREPEND,
+              });
+            });
+          }
+        })
+        .catch(() => {
+          this.hasError = true;
+          Flash(s__('GroupRoadmap|Something went wrong while fetching epics'));
+        });
+    },
     /**
      * Roadmap view works with absolute sizing and positioning
      * of following child components of RoadmapShell;
@@ -117,6 +142,49 @@ export default {
         this.isLoading = false;
       }, 200)();
     },
+    /**
+     * Once timeline is expanded (either with prepend or append)
+     * We need performing following actions;
+     *
+     * 1. Reset start and end edges of the timeline for
+     *    infinite scrolling to continue further.
+     * 2. Re-render timeline bars to account for
+     *    updated timeframe.
+     * 3. In case of prepending timeframe,
+     *    reset scroll-position (due to DOM prepend).
+     */
+    processExtendedTimeline({ extendType = EXTEND_AS.PREPEND, roadmapTimelineEl, itemsCount = 0 }) {
+      // Re-render timeline bars with updated timeline
+      eventHub.$emit('refreshTimeline', {
+        height: window.innerHeight - roadmapTimelineEl.offsetTop,
+        todayBarReady: extendType === EXTEND_AS.PREPEND,
+      });
+
+      if (extendType === EXTEND_AS.PREPEND) {
+        // When DOM is prepended with elements
+        // we compensate the scrolling for added elements' width
+        roadmapTimelineEl.parentElement.scrollBy(
+          roadmapTimelineEl.querySelector('.timeline-header-item').clientWidth * itemsCount,
+          0,
+        );
+      }
+    },
+    handleScrollToExtend(roadmapTimelineEl, extendType = EXTEND_AS.PREPEND) {
+      const timeframe = this.store.extendTimeframe(extendType);
+      this.$nextTick(() => {
+        this.processExtendedTimeline({
+          itemsCount: timeframe ? timeframe.length : 0,
+          extendType,
+          roadmapTimelineEl,
+        });
+
+        this.fetchEpicsForTimeframe({
+          timeframe,
+          roadmapTimelineEl,
+          extendType,
+        });
+      });
+    },
   },
 };
 </script>
@@ -135,6 +203,8 @@ export default {
       :epics="epics"
       :timeframe="timeframe"
       :current-group-id="currentGroupId"
+      @onScrollToStart="handleScrollToExtend"
+      @onScrollToEnd="handleScrollToExtend"
     />
     <epics-list-empty
       v-if="isEpicsListEmpty"
diff --git a/ee/app/assets/javascripts/roadmap/components/epic_item.vue b/ee/app/assets/javascripts/roadmap/components/epic_item.vue
index 59fcc1c59bc4..c46dd568b8e3 100644
--- a/ee/app/assets/javascripts/roadmap/components/epic_item.vue
+++ b/ee/app/assets/javascripts/roadmap/components/epic_item.vue
@@ -1,7 +1,11 @@
 <script>
+import _ from 'underscore';
+
 import epicItemDetails from './epic_item_details.vue';
 import epicItemTimeline from './epic_item_timeline.vue';
 
+import { EPIC_HIGHLIGHT_REMOVE_AFTER } from '../constants';
+
 export default {
   components: {
     epicItemDetails,
@@ -33,11 +37,36 @@ export default {
       required: true,
     },
   },
+  updated() {
+    this.removeHighlight();
+  },
+  methods: {
+    /**
+     * When new epics are added to the list on
+     * timeline scroll, we set `newEpic` flag
+     * as true and then use it in template
+     * to set `newly-added-epic` class for
+     * highlighting epic using CSS animations
+     *
+     * Once animation is complete, we need to
+     * remove the flag so that animation is not
+     * replayed when list is re-rendered.
+     */
+    removeHighlight() {
+      if (this.epic.newEpic) {
+        this.$nextTick(() => {
+          _.delay(() => {
+            this.epic.newEpic = false;
+          }, EPIC_HIGHLIGHT_REMOVE_AFTER);
+        });
+      }
+    },
+  },
 };
 </script>
 
 <template>
-  <div class="epics-list-item clearfix">
+  <div :class="{ 'newly-added-epic': epic.newEpic }" class="epics-list-item clearfix">
     <epic-item-details :epic="epic" :current-group-id="currentGroupId" />
     <epic-item-timeline
       v-for="(timeframeItem, index) in timeframe"
diff --git a/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue b/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue
index 03d79bd6c2bb..70a12e8a929c 100644
--- a/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue
+++ b/ee/app/assets/javascripts/roadmap/components/epic_item_timeline.vue
@@ -5,13 +5,9 @@ import QuartersPresetMixin from '../mixins/quarters_preset_mixin';
 import MonthsPresetMixin from '../mixins/months_preset_mixin';
 import WeeksPresetMixin from '../mixins/weeks_preset_mixin';
 
-import {
-  EPIC_DETAILS_CELL_WIDTH,
-  TIMELINE_CELL_MIN_WIDTH,
-  TIMELINE_END_OFFSET_FULL,
-  TIMELINE_END_OFFSET_HALF,
-  PRESET_TYPES,
-} from '../constants';
+import eventHub from '../event_hub';
+
+import { EPIC_DETAILS_CELL_WIDTH, TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from '../constants';
 
 export default {
   directives: {
@@ -66,6 +62,12 @@ export default {
       this.renderTimelineBar();
     },
   },
+  mounted() {
+    eventHub.$on('refreshTimeline', this.renderTimelineBar);
+  },
+  beforeDestroy() {
+    eventHub.$off('refreshTimeline', this.renderTimelineBar);
+  },
   methods: {
     /**
      * Gets cell width based on total number months for
@@ -89,49 +91,6 @@ export default {
       }
       return false;
     },
-    getTimelineBarEndOffsetHalf() {
-      if (this.presetType === PRESET_TYPES.QUARTERS) {
-        return TIMELINE_END_OFFSET_HALF;
-      } else if (this.presetType === PRESET_TYPES.MONTHS) {
-        return TIMELINE_END_OFFSET_HALF;
-      } else if (this.presetType === PRESET_TYPES.WEEKS) {
-        return this.getTimelineBarEndOffsetHalfForWeek();
-      }
-      return 0;
-    },
-    /**
-     * In case startDate or endDate for any epic is undefined or is out of range
-     * for current timeframe, we have to provide specific offset while
-     * setting width to ensure that;
-     *
-     * 1. Timeline bar ends at correct position based on end date.
-     * 2. A "triangle" shape is shown at the end of timeline bar
-     *    when endDate is out of range.
-     */
-    getTimelineBarEndOffset() {
-      let offset = 0;
-
-      if (
-        (this.epic.startDateOutOfRange && this.epic.endDateOutOfRange) ||
-        (this.epic.startDateUndefined && this.epic.endDateOutOfRange)
-      ) {
-        // If Epic startDate is undefined or out of range
-        // AND
-        // endDate is out of range
-        // Reduce offset size from the width to compensate for fadeout of timelinebar
-        // and/or showing triangle at the end and beginning
-        offset = TIMELINE_END_OFFSET_FULL;
-      } else if (this.epic.endDateOutOfRange) {
-        // If Epic end date is out of range
-        // Reduce offset size from the width to compensate for triangle (which is sized at 8px)
-        offset = this.getTimelineBarEndOffsetHalf();
-      } else {
-        // No offset needed if all dates are defined.
-        offset = 0;
-      }
-
-      return offset;
-    },
     /**
      * Renders timeline bar only if current
      * timeframe item has startDate for the epic.
@@ -160,9 +119,7 @@ export default {
         :href="epic.webUrl"
         :class="{
           'start-date-undefined': epic.startDateUndefined,
-          'start-date-outside': epic.startDateOutOfRange,
           'end-date-undefined': epic.endDateUndefined,
-          'end-date-outside': epic.endDateOutOfRange,
         }"
         :style="timelineBarStyles"
         class="timeline-bar"
diff --git a/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue b/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue
index f4aec7cc9813..d13fb7a10349 100644
--- a/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue
+++ b/ee/app/assets/javascripts/roadmap/components/epics_list_empty.vue
@@ -2,7 +2,7 @@
 import { s__, sprintf } from '~/locale';
 import { dateInWords } from '~/lib/utils/datetime_utility';
 
-import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
+import { PRESET_TYPES, emptyStateDefault, emptyStateWithFilters } from '../constants';
 
 import NewEpic from '../../epics/new_epic/components/new_epic.vue';
 
@@ -82,12 +82,12 @@ export default {
     },
     subMessage() {
       if (this.hasFiltersApplied) {
-        return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateWithFilters, {
+        return sprintf(emptyStateWithFilters, {
           startDate: this.timeframeRange.startDate,
           endDate: this.timeframeRange.endDate,
         });
       }
-      return sprintf(PRESET_DEFAULTS[this.presetType].emptyStateDefault, {
+      return sprintf(emptyStateDefault, {
         startDate: this.timeframeRange.startDate,
         endDate: this.timeframeRange.endDate,
       });
diff --git a/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue b/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue
index 0dd982908444..2597e3ad9b77 100644
--- a/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue
+++ b/ee/app/assets/javascripts/roadmap/components/epics_list_section.vue
@@ -3,6 +3,8 @@ import eventHub from '../event_hub';
 
 import SectionMixin from '../mixins/section_mixin';
 
+import { TIMELINE_CELL_MIN_WIDTH } from '../constants';
+
 import epicItem from './epic_item.vue';
 
 export default {
@@ -72,12 +74,18 @@ export default {
   },
   mounted() {
     eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
+    eventHub.$on('refreshTimeline', () => {
+      this.initEmptyRow(false);
+    });
     this.$nextTick(() => {
       this.initMounted();
     });
   },
   beforeDestroy() {
     eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
+    eventHub.$off('refreshTimeline', () => {
+      this.initEmptyRow(false);
+    });
   },
   methods: {
     initMounted() {
@@ -104,7 +112,7 @@ export default {
      * based on height of available list items and sets it to component
      * props.
      */
-    initEmptyRow() {
+    initEmptyRow(showEmptyRow = false) {
       const children = this.$children;
       let approxChildrenHeight = children[0].$el.clientHeight * this.epics.length;
 
@@ -122,18 +130,16 @@ export default {
         this.emptyRowHeight = this.shellHeight - approxChildrenHeight;
         this.showEmptyRow = true;
       } else {
+        this.showEmptyRow = showEmptyRow;
         this.showBottomShadow = true;
       }
     },
     /**
-     * `clientWidth` is full width of list section, and we need to
-     * scroll up to 60% of the view where today indicator is present.
-     *
-     * Reason for 60% is that "today" always falls in the middle of timeframe range.
+     * Scroll timeframe to the right of the timeline
+     * by half the column size
      */
     scrollToTodayIndicator() {
-      const uptoTodayIndicator = Math.ceil((this.$el.clientWidth * 60) / 100);
-      this.$el.scrollTo(uptoTodayIndicator, 0);
+      this.$el.parentElement.scrollBy(TIMELINE_CELL_MIN_WIDTH / 2, 0);
     },
     handleEpicsListScroll({ scrollTop, clientHeight, scrollHeight }) {
       this.showBottomShadow = Math.ceil(scrollTop) + clientHeight < scrollHeight;
diff --git a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue
index 542e13ac3223..b9614c551add 100644
--- a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue
+++ b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_item.vue
@@ -29,8 +29,6 @@ export default {
 
     return {
       currentDate,
-      quarterBeginDate: this.timeframeItem.range[0],
-      quarterEndDate: this.timeframeItem.range[2],
     };
   },
   computed: {
@@ -39,6 +37,12 @@ export default {
         width: `${this.itemWidth}px`,
       };
     },
+    quarterBeginDate() {
+      return this.timeframeItem.range[0];
+    },
+    quarterEndDate() {
+      return this.timeframeItem.range[2];
+    },
     timelineHeaderLabel() {
       const { quarterSequence } = this.timeframeItem;
       if (quarterSequence === 1 || (this.timeframeIndex === 0 && quarterSequence !== 1)) {
diff --git a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue
index c80f73f36907..4120c1c1986e 100644
--- a/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue
+++ b/ee/app/assets/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item.vue
@@ -20,13 +20,13 @@ export default {
       required: true,
     },
   },
-  data() {
-    return {
-      quarterBeginDate: this.timeframeItem.range[0],
-      quarterEndDate: this.timeframeItem.range[2],
-    };
-  },
   computed: {
+    quarterBeginDate() {
+      return this.timeframeItem.range[0];
+    },
+    quarterEndDate() {
+      return this.timeframeItem.range[2];
+    },
     headerSubItems() {
       return this.timeframeItem.range;
     },
diff --git a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue
index fcf983a6a15a..ab6f62b16dfa 100644
--- a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue
+++ b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_item.vue
@@ -29,12 +29,8 @@ export default {
     const currentDate = new Date();
     currentDate.setHours(0, 0, 0, 0);
 
-    const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
-    lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
-
     return {
       currentDate,
-      lastDayOfCurrentWeek,
     };
   },
   computed: {
@@ -43,19 +39,35 @@ export default {
         width: `${this.itemWidth}px`,
       };
     },
+    lastDayOfCurrentWeek() {
+      const lastDayOfCurrentWeek = new Date(this.timeframeItem.getTime());
+      lastDayOfCurrentWeek.setDate(lastDayOfCurrentWeek.getDate() + 7);
+
+      return lastDayOfCurrentWeek;
+    },
     timelineHeaderLabel() {
-      if (this.timeframeIndex === 0) {
+      const timeframeItemMonth = this.timeframeItem.getMonth();
+      const timeframeItemDate = this.timeframeItem.getDate();
+
+      if (this.timeframeIndex === 0 || (timeframeItemMonth === 0 && timeframeItemDate <= 7)) {
         return `${this.timeframeItem.getFullYear()} ${monthInWords(
           this.timeframeItem,
           true,
-        )} ${this.timeframeItem.getDate()}`;
+        )} ${timeframeItemDate}`;
       }
-      return `${monthInWords(this.timeframeItem, true)} ${this.timeframeItem.getDate()}`;
+
+      return `${monthInWords(this.timeframeItem, true)} ${timeframeItemDate}`;
     },
     timelineHeaderClass() {
-      if (this.currentDate >= this.timeframeItem && this.currentDate <= this.lastDayOfCurrentWeek) {
+      const currentDateTime = this.currentDate.getTime();
+      const lastDayOfCurrentWeekTime = this.lastDayOfCurrentWeek.getTime();
+
+      if (
+        currentDateTime >= this.timeframeItem.getTime() &&
+        currentDateTime <= lastDayOfCurrentWeekTime
+      ) {
         return 'label-dark label-bold';
-      } else if (this.currentDate < this.lastDayOfCurrentWeek) {
+      } else if (currentDateTime < lastDayOfCurrentWeekTime) {
         return 'label-dark';
       }
       return '';
diff --git a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue
index cd9528fa8527..ed7e83fd3c04 100644
--- a/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue
+++ b/ee/app/assets/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item.vue
@@ -18,28 +18,26 @@ export default {
       required: true,
     },
   },
-  data() {
-    const timeframeItem = new Date(this.timeframeItem.getTime());
-    const headerSubItems = new Array(7)
-      .fill()
-      .map(
-        (val, i) =>
-          new Date(
-            timeframeItem.getFullYear(),
-            timeframeItem.getMonth(),
-            timeframeItem.getDate() + i,
-          ),
-      );
-
-    return {
-      headerSubItems,
-    };
-  },
   computed: {
+    headerSubItems() {
+      const timeframeItem = new Date(this.timeframeItem.getTime());
+      const headerSubItems = new Array(7)
+        .fill()
+        .map(
+          (val, i) =>
+            new Date(
+              timeframeItem.getFullYear(),
+              timeframeItem.getMonth(),
+              timeframeItem.getDate() + i,
+            ),
+        );
+
+      return headerSubItems;
+    },
     hasToday() {
       return (
-        this.currentDate >= this.headerSubItems[0] &&
-        this.currentDate <= this.headerSubItems[this.headerSubItems.length - 1]
+        this.currentDate.getTime() >= this.headerSubItems[0].getTime() &&
+        this.currentDate.getTime() <= this.headerSubItems[this.headerSubItems.length - 1].getTime()
       );
     },
   },
diff --git a/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue b/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue
index 780d9164587d..9b6b0ac3f5b1 100644
--- a/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue
+++ b/ee/app/assets/javascripts/roadmap/components/roadmap_shell.vue
@@ -1,6 +1,7 @@
 <script>
 import bp from '~/breakpoints';
-import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH } from '../constants';
+import { isInViewport } from '~/lib/utils/common_utils';
+import { SCROLL_BAR_SIZE, EPIC_ITEM_HEIGHT, SHELL_MIN_WIDTH, EXTEND_AS } from '../constants';
 import eventHub from '../event_hub';
 
 import epicsListSection from './epics_list_section.vue';
@@ -34,6 +35,7 @@ export default {
       shellWidth: 0,
       shellHeight: 0,
       noScroll: false,
+      timeframeStartOffset: 0,
     };
   },
   computed: {
@@ -61,6 +63,11 @@ export default {
         this.shellHeight = window.innerHeight - this.$el.offsetTop;
         this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
         this.shellWidth = this.$el.parentElement.clientWidth + this.getWidthOffset();
+
+        this.timeframeStartOffset = this.$refs.roadmapTimeline.$el
+          .querySelector('.timeline-header-item')
+          .querySelector('.item-sublabel .sublabel-value:first-child')
+          .getBoundingClientRect().left;
       }
     });
   },
@@ -70,6 +77,22 @@ export default {
     },
     handleScroll() {
       const { scrollTop, scrollLeft, clientHeight, scrollHeight } = this.$el;
+      const timelineEdgeStartEl = this.$refs.roadmapTimeline.$el
+        .querySelector('.timeline-header-item')
+        .querySelector('.item-sublabel .sublabel-value:first-child');
+      const timelineEdgeEndEl = this.$refs.roadmapTimeline.$el
+        .querySelector('.timeline-header-item:last-child')
+        .querySelector('.item-sublabel .sublabel-value:last-child');
+
+      // If timeline was scrolled to start
+      if (isInViewport(timelineEdgeStartEl, { left: this.timeframeStartOffset })) {
+        this.$emit('onScrollToStart', this.$refs.roadmapTimeline.$el, EXTEND_AS.PREPEND);
+      } else if (isInViewport(timelineEdgeEndEl)) {
+        // If timeline was scrolled to end
+        this.$emit('onScrollToEnd', this.$refs.roadmapTimeline.$el, EXTEND_AS.APPEND);
+      }
+
+      this.noScroll = this.shellHeight > EPIC_ITEM_HEIGHT * (this.epics.length + 1);
       eventHub.$emit('epicsListScrolled', { scrollTop, scrollLeft, clientHeight, scrollHeight });
     },
   },
@@ -84,6 +107,7 @@ export default {
     @scroll="handleScroll"
   >
     <roadmap-timeline-section
+      ref="roadmapTimeline"
       :preset-type="presetType"
       :epics="epics"
       :timeframe="timeframe"
diff --git a/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue b/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue
index 5244ae65cf50..3d142f5aaa81 100644
--- a/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue
+++ b/ee/app/assets/javascripts/roadmap/components/timeline_today_indicator.vue
@@ -29,10 +29,12 @@ export default {
   mounted() {
     eventHub.$on('epicsListRendered', this.handleEpicsListRender);
     eventHub.$on('epicsListScrolled', this.handleEpicsListScroll);
+    eventHub.$on('refreshTimeline', this.handleEpicsListRender);
   },
   beforeDestroy() {
     eventHub.$off('epicsListRendered', this.handleEpicsListRender);
     eventHub.$off('epicsListScrolled', this.handleEpicsListScroll);
+    eventHub.$off('refreshTimeline', this.handleEpicsListRender);
   },
   methods: {
     /**
@@ -40,7 +42,7 @@ export default {
      * and renders vertical line over the area where
      * today falls in current timeline
      */
-    handleEpicsListRender({ height }) {
+    handleEpicsListRender({ height, todayBarReady }) {
       let left = 0;
 
       // Get total days of current timeframe Item and then
@@ -68,7 +70,7 @@ export default {
         height: `${height + 20}px`,
         left: `${left}%`,
       };
-      this.todayBarReady = true;
+      this.todayBarReady = todayBarReady === undefined ? true : todayBarReady;
     },
     handleEpicsListScroll() {
       const indicatorX = this.$el.getBoundingClientRect().x;
diff --git a/ee/app/assets/javascripts/roadmap/constants.js b/ee/app/assets/javascripts/roadmap/constants.js
index 80cf0fa44bfb..a76b299b6c0b 100644
--- a/ee/app/assets/javascripts/roadmap/constants.js
+++ b/ee/app/assets/javascripts/roadmap/constants.js
@@ -1,7 +1,5 @@
 import { s__ } from '~/locale';
 
-export const TIMEFRAME_LENGTH = 6;
-
 export const EPIC_DETAILS_CELL_WIDTH = 320;
 
 export const EPIC_ITEM_HEIGHT = 50;
@@ -12,9 +10,7 @@ export const SHELL_MIN_WIDTH = 1620;
 
 export const SCROLL_BAR_SIZE = 15;
 
-export const TIMELINE_END_OFFSET_HALF = 8;
-
-export const TIMELINE_END_OFFSET_FULL = 16;
+export const EPIC_HIGHLIGHT_REMOVE_AFTER = 3000;
 
 export const PRESET_TYPES = {
   QUARTERS: 'QUARTERS',
@@ -22,32 +18,27 @@ export const PRESET_TYPES = {
   WEEKS: 'WEEKS',
 };
 
+export const EXTEND_AS = {
+  PREPEND: 'prepend',
+  APPEND: 'append',
+};
+
+export const emptyStateDefault = s__(
+  'GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}.',
+);
+
+export const emptyStateWithFilters = s__(
+  'GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}.',
+);
+
 export const PRESET_DEFAULTS = {
   QUARTERS: {
-    TIMEFRAME_LENGTH: 18,
-    emptyStateDefault: s__(
-      'GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
-    emptyStateWithFilters: s__(
-      'GroupRoadmap|To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
+    TIMEFRAME_LENGTH: 21,
   },
   MONTHS: {
-    TIMEFRAME_LENGTH: 7,
-    emptyStateDefault: s__(
-      'GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
-    emptyStateWithFilters: s__(
-      'GroupRoadmap|To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
+    TIMEFRAME_LENGTH: 8,
   },
   WEEKS: {
-    TIMEFRAME_LENGTH: 42,
-    emptyStateDefault: s__(
-      'GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
-    emptyStateWithFilters: s__(
-      'GroupRoadmap|To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}.',
-    ),
+    TIMEFRAME_LENGTH: 7,
   },
 };
diff --git a/ee/app/assets/javascripts/roadmap/index.js b/ee/app/assets/javascripts/roadmap/index.js
index 252d3ead4d3b..9c8234716188 100644
--- a/ee/app/assets/javascripts/roadmap/index.js
+++ b/ee/app/assets/javascripts/roadmap/index.js
@@ -5,7 +5,7 @@ import Translate from '~/vue_shared/translate';
 import { parseBoolean } from '~/lib/utils/common_utils';
 import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
 
-import { PRESET_TYPES } from './constants';
+import { PRESET_TYPES, EPIC_DETAILS_CELL_WIDTH } from './constants';
 
 import { getTimeframeForPreset, getEpicsPathForPreset } from './utils/roadmap_utils';
 
@@ -48,23 +48,38 @@ export default () => {
           ? dataset.presetType
           : PRESET_TYPES.MONTHS;
       const filterQueryString = window.location.search.substring(1);
-      const timeframe = getTimeframeForPreset(presetType);
-      const epicsPath = getEpicsPathForPreset({
+      const timeframe = getTimeframeForPreset(
+        presetType,
+        window.innerWidth - el.offsetLeft - EPIC_DETAILS_CELL_WIDTH,
+      );
+      const initialEpicsPath = getEpicsPathForPreset({
         basePath: dataset.epicsPath,
+        epicsState: dataset.epicsState,
         filterQueryString,
         presetType,
         timeframe,
-        state: dataset.epicsState,
       });
 
-      const store = new RoadmapStore(parseInt(dataset.groupId, 0), timeframe, presetType);
-      const service = new RoadmapService(epicsPath);
+      const store = new RoadmapStore({
+        groupId: parseInt(dataset.groupId, 0),
+        sortedBy: dataset.sortedBy,
+        timeframe,
+        presetType,
+      });
+
+      const service = new RoadmapService({
+        initialEpicsPath,
+        filterQueryString,
+        basePath: dataset.epicsPath,
+        epicsState: dataset.epicsState,
+      });
 
       return {
         store,
         service,
         presetType,
         hasFiltersApplied,
+        epicsState: dataset.epicsState,
         newEpicEndpoint: dataset.newEpicEndpoint,
         emptyStateIllustrationPath: dataset.emptyStateIllustration,
       };
@@ -76,6 +91,7 @@ export default () => {
           service: this.service,
           presetType: this.presetType,
           hasFiltersApplied: this.hasFiltersApplied,
+          epicsState: this.epicsState,
           newEpicEndpoint: this.newEpicEndpoint,
           emptyStateIllustrationPath: this.emptyStateIllustrationPath,
         },
diff --git a/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js
index 01c6d33779e9..2b2dbf02f256 100644
--- a/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js
+++ b/ee/app/assets/javascripts/roadmap/mixins/months_preset_mixin.js
@@ -1,7 +1,5 @@
 import { totalDaysInMonth } from '~/lib/utils/datetime_utility';
 
-import { TIMELINE_END_OFFSET_HALF } from '../constants';
-
 export default {
   methods: {
     /**
@@ -17,10 +15,10 @@ export default {
      * Check if current epic ends within current month (timeline cell)
      */
     isTimeframeUnderEndDateForMonth(timeframeItem, epicEndDate) {
-      return (
-        timeframeItem.getYear() <= epicEndDate.getYear() &&
-        timeframeItem.getMonth() === epicEndDate.getMonth()
-      );
+      if (epicEndDate.getFullYear() <= timeframeItem.getFullYear()) {
+        return epicEndDate.getMonth() === timeframeItem.getMonth();
+      }
+      return epicEndDate.getTime() < timeframeItem.getTime();
     },
     /**
      * Return timeline bar width for current month (timeline cell) based on
@@ -61,16 +59,6 @@ export default {
         return 'left: 0;';
       }
 
-      // If Epic end date is out of range
-      const lastTimeframeItem = this.timeframe[this.timeframe.length - 1];
-      // Check if Epic start date falls within last month of the timeframe
-      if (
-        this.epic.startDate.getMonth() === lastTimeframeItem.getMonth() &&
-        this.epic.startDate.getFullYear() === lastTimeframeItem.getFullYear()
-      ) {
-        // Compensate for triangle size
-        return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
-      }
       // Calculate proportional offset based on startDate and total days in
       // current month.
       return `left: ${(startDate / daysInMonth) * 100}%;`;
@@ -99,7 +87,6 @@ export default {
 
       const indexOfCurrentMonth = this.timeframe.indexOf(this.timeframeItem);
       const cellWidth = this.getCellWidth();
-      const offsetEnd = this.getTimelineBarEndOffset();
       const epicStartDate = this.epic.startDate;
       const epicEndDate = this.epic.endDate;
 
@@ -149,8 +136,7 @@ export default {
         }
       }
 
-      // Reduce any offset from total width and round it off.
-      return timelineBarWidth - offsetEnd;
+      return timelineBarWidth;
     },
   },
 };
diff --git a/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js
index 9b70b17e18bb..402c6de03a0d 100644
--- a/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js
+++ b/ee/app/assets/javascripts/roadmap/mixins/quarters_preset_mixin.js
@@ -1,7 +1,5 @@
 import { totalDaysInQuarter, dayInQuarter } from '~/lib/utils/datetime_utility';
 
-import { TIMELINE_END_OFFSET_HALF } from '../constants';
-
 export default {
   methods: {
     /**
@@ -11,7 +9,10 @@ export default {
       const quarterStart = this.timeframeItem.range[0];
       const quarterEnd = this.timeframeItem.range[2];
 
-      return this.epic.startDate >= quarterStart && this.epic.startDate <= quarterEnd;
+      return (
+        this.epic.startDate.getTime() >= quarterStart.getTime() &&
+        this.epic.startDate.getTime() <= quarterEnd.getTime()
+      );
     },
     /**
      * Check if current epic ends within current quarter (timeline cell)
@@ -19,7 +20,7 @@ export default {
     isTimeframeUnderEndDateForQuarter(timeframeItem, epicEndDate) {
       const quarterEnd = timeframeItem.range[2];
 
-      return epicEndDate <= quarterEnd;
+      return epicEndDate.getTime() <= quarterEnd.getTime();
     },
     /**
      * Return timeline bar width for current quarter (timeline cell) based on
@@ -57,14 +58,6 @@ export default {
         return 'left: 0;';
       }
 
-      const lastTimeframeItem = this.timeframe[this.timeframe.length - 1].range[2];
-      if (
-        this.epic.startDate >= this.timeframe[this.timeframe.length - 1].range[0] &&
-        this.epic.startDate <= lastTimeframeItem
-      ) {
-        return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
-      }
-
       return `left: ${(startDay / daysInQuarter) * 100}%;`;
     },
     /**
@@ -95,7 +88,6 @@ export default {
 
       const indexOfCurrentQuarter = this.timeframe.indexOf(this.timeframeItem);
       const cellWidth = this.getCellWidth();
-      const offsetEnd = this.getTimelineBarEndOffset();
       const epicStartDate = this.epic.startDate;
       const epicEndDate = this.epic.endDate;
 
@@ -140,7 +132,7 @@ export default {
         }
       }
 
-      return timelineBarWidth - offsetEnd;
+      return timelineBarWidth;
     },
   },
 };
diff --git a/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js b/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js
index 67b3c4a9c9e4..28f332a515f6 100644
--- a/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js
+++ b/ee/app/assets/javascripts/roadmap/mixins/weeks_preset_mixin.js
@@ -1,4 +1,4 @@
-import { TIMELINE_END_OFFSET_HALF } from '../constants';
+import { newDate } from '~/lib/utils/datetime_utility';
 
 export default {
   methods: {
@@ -7,16 +7,19 @@ export default {
      */
     hasStartDateForWeek() {
       const firstDayOfWeek = this.timeframeItem;
-      const lastDayOfWeek = new Date(this.timeframeItem.getTime());
+      const lastDayOfWeek = newDate(this.timeframeItem);
       lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
 
-      return this.epic.startDate >= firstDayOfWeek && this.epic.startDate <= lastDayOfWeek;
+      return (
+        this.epic.startDate.getTime() >= firstDayOfWeek.getTime() &&
+        this.epic.startDate.getTime() <= lastDayOfWeek.getTime()
+      );
     },
     /**
      * Return last date of the week from provided timeframeItem
      */
     getLastDayOfWeek(timeframeItem) {
-      const lastDayOfWeek = new Date(timeframeItem.getTime());
+      const lastDayOfWeek = newDate(timeframeItem);
       lastDayOfWeek.setDate(lastDayOfWeek.getDate() + 6);
       return lastDayOfWeek;
     },
@@ -25,7 +28,7 @@ export default {
      */
     isTimeframeUnderEndDateForWeek(timeframeItem, epicEndDate) {
       const lastDayOfWeek = this.getLastDayOfWeek(timeframeItem);
-      return epicEndDate <= lastDayOfWeek;
+      return epicEndDate.getTime() <= lastDayOfWeek.getTime();
     },
     /**
      * Return timeline bar width for current week (timeline cell) based on
@@ -37,14 +40,6 @@ export default {
 
       return Math.min(cellWidth, barWidth);
     },
-    /**
-     * Gets timelinebar end offset based width of single day
-     * and TIMELINE_END_OFFSET_HALF
-     */
-    getTimelineBarEndOffsetHalfForWeek() {
-      const dayWidth = this.getCellWidth() / 7;
-      return TIMELINE_END_OFFSET_HALF + dayWidth * 0.5;
-    },
     /**
      * In case startDate for any epic is undefined or is out of range
      * for current timeframe, we have to provide specific offset while
@@ -73,15 +68,6 @@ export default {
         return 'left: 0;';
       }
 
-      const lastTimeframeItem = new Date(this.timeframe[this.timeframe.length - 1].getTime());
-      lastTimeframeItem.setDate(lastTimeframeItem.getDate() + 6);
-      if (
-        this.epic.startDate >= this.timeframe[this.timeframe.length - 1] &&
-        this.epic.startDate <= lastTimeframeItem
-      ) {
-        return `right: ${TIMELINE_END_OFFSET_HALF}px;`;
-      }
-
       return `left: ${startDate * dayWidth - dayWidth / 2}px;`;
     },
     /**
@@ -111,7 +97,6 @@ export default {
 
       const indexOfCurrentWeek = this.timeframe.indexOf(this.timeframeItem);
       const cellWidth = this.getCellWidth();
-      const offsetEnd = this.getTimelineBarEndOffset();
       const epicStartDate = this.epic.startDate;
       const epicEndDate = this.epic.endDate;
 
@@ -135,7 +120,7 @@ export default {
         }
       }
 
-      return timelineBarWidth - offsetEnd;
+      return timelineBarWidth;
     },
   },
 };
diff --git a/ee/app/assets/javascripts/roadmap/service/roadmap_service.js b/ee/app/assets/javascripts/roadmap/service/roadmap_service.js
index ea4d58aeff18..4c2a0cf81ef0 100644
--- a/ee/app/assets/javascripts/roadmap/service/roadmap_service.js
+++ b/ee/app/assets/javascripts/roadmap/service/roadmap_service.js
@@ -1,11 +1,28 @@
 import axios from '~/lib/utils/axios_utils';
 
+import { getEpicsPathForPreset } from '../utils/roadmap_utils';
+
 export default class RoadmapService {
-  constructor(epicsPath) {
-    this.epicsPath = epicsPath;
+  constructor({ basePath, epicsState, filterQueryString, initialEpicsPath }) {
+    this.basePath = basePath;
+    this.epicsState = epicsState;
+    this.filterQueryString = filterQueryString;
+    this.initialEpicsPath = initialEpicsPath;
   }
 
   getEpics() {
-    return axios.get(this.epicsPath);
+    return axios.get(this.initialEpicsPath);
+  }
+
+  getEpicsForTimeframe(presetType, timeframe) {
+    const epicsPath = getEpicsPathForPreset({
+      basePath: this.basePath,
+      epicsState: this.epicsState,
+      filterQueryString: this.filterQueryString,
+      presetType,
+      timeframe,
+    });
+
+    return axios.get(epicsPath);
   }
 }
diff --git a/ee/app/assets/javascripts/roadmap/store/roadmap_store.js b/ee/app/assets/javascripts/roadmap/store/roadmap_store.js
index fb4a25d17b66..64ca6487702c 100644
--- a/ee/app/assets/javascripts/roadmap/store/roadmap_store.js
+++ b/ee/app/assets/javascripts/roadmap/store/roadmap_store.js
@@ -1,16 +1,19 @@
 import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { parsePikadayDate } from '~/lib/utils/datetime_utility';
+import { newDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
 
-import { PRESET_TYPES } from '../constants';
+import { extendTimeframeForPreset, sortEpics } from '../utils/roadmap_utils';
+import { PRESET_TYPES, EXTEND_AS } from '../constants';
 
 export default class RoadmapStore {
-  constructor(groupId, timeframe, presetType) {
+  constructor({ groupId, timeframe, presetType, sortedBy }) {
     this.state = {};
     this.state.epics = [];
+    this.state.epicIds = [];
     this.state.currentGroupId = groupId;
     this.state.timeframe = timeframe;
 
     this.presetType = presetType;
+    this.sortedBy = sortedBy;
     this.initTimeframeThreshold();
   }
 
@@ -27,24 +30,33 @@ export default class RoadmapStore {
       this.timeframeEndDate = this.state.timeframe[lastTimeframeIndex];
     } else if (this.presetType === PRESET_TYPES.WEEKS) {
       this.timeframeStartDate = startFrame;
-      this.timeframeEndDate = new Date(this.state.timeframe[lastTimeframeIndex].getTime());
+      this.timeframeEndDate = newDate(this.state.timeframe[lastTimeframeIndex]);
       this.timeframeEndDate.setDate(this.timeframeEndDate.getDate() + 7);
     }
   }
 
   setEpics(epics) {
-    this.state.epics = epics.reduce((filteredEpics, epic) => {
-      const formattedEpic = RoadmapStore.formatEpicDetails(
-        epic,
-        this.timeframeStartDate,
-        this.timeframeEndDate,
-      );
-      // Exclude any Epic that has invalid dates
-      if (formattedEpic.startDate <= formattedEpic.endDate) {
-        filteredEpics.push(formattedEpic);
-      }
-      return filteredEpics;
-    }, []);
+    this.state.epicIds = [];
+    this.state.epics = RoadmapStore.filterInvalidEpics({
+      timeframeStartDate: this.timeframeStartDate,
+      timeframeEndDate: this.timeframeEndDate,
+      state: this.state,
+      epics,
+    });
+  }
+
+  addEpics(epics) {
+    this.state.epics = this.state.epics.concat(
+      RoadmapStore.filterInvalidEpics({
+        timeframeStartDate: this.timeframeStartDate,
+        timeframeEndDate: this.timeframeEndDate,
+        state: this.state,
+        newEpic: true,
+        epics,
+      }),
+    );
+
+    sortEpics(this.state.epics, this.sortedBy);
   }
 
   getEpics() {
@@ -59,6 +71,57 @@ export default class RoadmapStore {
     return this.state.timeframe;
   }
 
+  extendTimeframe(extendAs = EXTEND_AS.PREPEND) {
+    const timeframeToExtend = extendTimeframeForPreset({
+      presetType: this.presetType,
+      extendAs,
+      initialDate: extendAs === EXTEND_AS.PREPEND ? this.timeframeStartDate : this.timeframeEndDate,
+    });
+
+    if (extendAs === EXTEND_AS.PREPEND) {
+      this.state.timeframe.unshift(...timeframeToExtend);
+    } else {
+      this.state.timeframe.push(...timeframeToExtend);
+    }
+
+    this.initTimeframeThreshold();
+
+    this.state.epics.forEach(epic =>
+      RoadmapStore.processEpicDates(epic, this.timeframeStartDate, this.timeframeEndDate),
+    );
+
+    return timeframeToExtend;
+  }
+
+  static filterInvalidEpics({
+    epics,
+    timeframeStartDate,
+    timeframeEndDate,
+    state,
+    newEpic = false,
+  }) {
+    return epics.reduce((filteredEpics, epic) => {
+      const formattedEpic = RoadmapStore.formatEpicDetails(
+        epic,
+        timeframeStartDate,
+        timeframeEndDate,
+      );
+      // Exclude any Epic that has invalid dates
+      // or is already present in Roadmap timeline
+      if (
+        formattedEpic.startDate <= formattedEpic.endDate &&
+        state.epicIds.indexOf(formattedEpic.id) < 0
+      ) {
+        Object.assign(formattedEpic, {
+          newEpic,
+        });
+        filteredEpics.push(formattedEpic);
+        state.epicIds.push(formattedEpic.id);
+      }
+      return filteredEpics;
+    }, []);
+  }
+
   /**
    * This method constructs Epic object and assigns proxy dates
    * in case start or end dates are unavailable.
@@ -73,44 +136,75 @@ export default class RoadmapStore {
     if (rawEpic.start_date) {
       // If startDate is present
       const startDate = parsePikadayDate(rawEpic.start_date);
-
-      if (startDate <= timeframeStartDate) {
-        // If startDate is less than first timeframe item
-        // startDate is out of range;
-        epicItem.startDateOutOfRange = true;
-        // store original start date in different object
-        epicItem.originalStartDate = startDate;
-        // Use startDate object to set a proxy date so
-        // that timeline bar can render it.
-        epicItem.startDate = new Date(timeframeStartDate.getTime());
-      } else {
-        // startDate is within timeframe range
-        epicItem.startDate = startDate;
-      }
+      epicItem.startDate = startDate;
+      epicItem.originalStartDate = startDate;
     } else {
-      // Start date is not available
+      // startDate is not available
       epicItem.startDateUndefined = true;
-      // Set proxy date so that timeline bar can render it.
-      epicItem.startDate = new Date(timeframeStartDate.getTime());
     }
 
-    // Same as above but for endDate
-    // This entire chunk can be moved into generic method
-    // but we're keeping it here for the sake of simplicity.
     if (rawEpic.end_date) {
+      // If endDate is present
       const endDate = parsePikadayDate(rawEpic.end_date);
-      if (endDate >= timeframeEndDate) {
-        epicItem.endDateOutOfRange = true;
-        epicItem.originalEndDate = endDate;
-        epicItem.endDate = new Date(timeframeEndDate.getTime());
-      } else {
-        epicItem.endDate = endDate;
-      }
+      epicItem.endDate = endDate;
+      epicItem.originalEndDate = endDate;
     } else {
+      // endDate is not available
       epicItem.endDateUndefined = true;
-      epicItem.endDate = new Date(timeframeEndDate.getTime());
     }
 
+    RoadmapStore.processEpicDates(epicItem, timeframeStartDate, timeframeEndDate);
+
     return epicItem;
   }
+
+  static processEpicDates(epic, timeframeStartDate, timeframeEndDate) {
+    if (!epic.startDateUndefined) {
+      // If startDate is less than first timeframe item
+      if (epic.originalStartDate.getTime() < timeframeStartDate.getTime()) {
+        Object.assign(epic, {
+          // startDate is out of range
+          startDateOutOfRange: true,
+          // Use startDate object to set a proxy date so
+          // that timeline bar can render it.
+          startDate: newDate(timeframeStartDate),
+        });
+      } else {
+        Object.assign(epic, {
+          // startDate is within range
+          startDateOutOfRange: false,
+          // Set startDate to original startDate
+          startDate: newDate(epic.originalStartDate),
+        });
+      }
+    } else {
+      Object.assign(epic, {
+        startDate: newDate(timeframeStartDate),
+      });
+    }
+
+    if (!epic.endDateUndefined) {
+      // If endDate is greater than last timeframe item
+      if (epic.originalEndDate.getTime() > timeframeEndDate.getTime()) {
+        Object.assign(epic, {
+          // endDate is out of range
+          endDateOutOfRange: true,
+          // Use endDate object to set a proxy date so
+          // that timeline bar can render it.
+          endDate: newDate(timeframeEndDate),
+        });
+      } else {
+        Object.assign(epic, {
+          // startDate is within range
+          endDateOutOfRange: false,
+          // Set startDate to original startDate
+          endDate: newDate(epic.originalEndDate),
+        });
+      }
+    } else {
+      Object.assign(epic, {
+        endDate: newDate(timeframeEndDate),
+      });
+    }
+  }
 }
diff --git a/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js b/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
index 22d2d388f5ae..d638981e3a84 100644
--- a/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
+++ b/ee/app/assets/javascripts/roadmap/utils/roadmap_utils.js
@@ -1,15 +1,23 @@
-import { getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
+import { newDate, getTimeframeWindowFrom, totalDaysInMonth } from '~/lib/utils/datetime_utility';
 
-import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
+import { PRESET_TYPES, PRESET_DEFAULTS, EXTEND_AS, TIMELINE_CELL_MIN_WIDTH } from '../constants';
+
+const monthsForQuarters = {
+  1: [0, 1, 2],
+  2: [3, 4, 5],
+  3: [6, 7, 8],
+  4: [9, 10, 11],
+};
 
 /**
  * This method returns array of Objects representing Quarters based on provided initialDate
  *
  * For eg; If initialDate is 15th Jan 2018
  *         Then as per Roadmap specs, we need to show
- *         1 quarter before current quarter AND
+ *         2 quarters before current quarters
+ *         current quarter AND
  *         4 quarters after current quarter
- *         thus, total of 6 quarters.
+ *         thus, total of 7 quarters (21 Months).
  *
  * So returned array from this method will be;
  *        [
@@ -47,37 +55,32 @@ import { PRESET_TYPES, PRESET_DEFAULTS } from '../constants';
  *
  * @param {Date} initialDate
  */
-export const getTimeframeForQuartersView = (initialDate = new Date()) => {
-  const startDate = initialDate;
+export const getTimeframeForQuartersView = (initialDate = new Date(), timeframe = []) => {
+  const startDate = newDate(initialDate);
   startDate.setHours(0, 0, 0, 0);
 
-  const monthsForQuarters = {
-    1: [0, 1, 2],
-    2: [3, 4, 5],
-    3: [6, 7, 8],
-    4: [9, 10, 11],
-  };
-
-  // Get current quarter for current month
-  const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
-  // Get index of current month in current quarter
-  // It could be 0, 1, 2 (i.e. first, second or third)
-  const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
-    startDate.getMonth(),
-  );
+  if (!timeframe.length) {
+    // Get current quarter for current month
+    const currentQuarter = Math.floor((startDate.getMonth() + 3) / 3);
+    // Get index of current month in current quarter
+    // It could be 0, 1, 2 (i.e. first, second or third)
+    const currentMonthInCurrentQuarter = monthsForQuarters[currentQuarter].indexOf(
+      startDate.getMonth(),
+    );
 
-  // To move start back to first month of previous quarter
-  // Adding quarter size (3) to month order will give us
-  // exact number of months we need to go back in time
-  const startMonth = currentMonthInCurrentQuarter + 3;
-  const quartersTimeframe = [];
-  // Move startDate to first month of previous quarter
-  startDate.setMonth(startDate.getMonth() - startMonth);
+    // To move start back to first month of 2 quarters prior by
+    // adding quarter size (3 + 3) to month order will give us
+    // exact number of months we need to go back in time
+    const startMonth = currentMonthInCurrentQuarter + 6;
+    // Move startDate to first month of previous quarter
+    startDate.setMonth(startDate.getMonth() - startMonth);
 
-  // Get timeframe for the length we determined for this preset
-  // start from the startDate
-  const timeframe = getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH);
+    // Get timeframe for the length we determined for this preset
+    // start from the startDate
+    timeframe.push(...getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH));
+  }
 
+  const quartersTimeframe = [];
   // Iterate over the timeframe and break it down
   // in chunks of quarters
   for (let i = 0; i < timeframe.length; i += 3) {
@@ -100,83 +103,246 @@ export const getTimeframeForQuartersView = (initialDate = new Date()) => {
   return quartersTimeframe;
 };
 
+export const extendTimeframeForQuartersView = (initialDate = new Date(), length) => {
+  const startDate = newDate(initialDate);
+  startDate.setDate(1);
+
+  startDate.setMonth(startDate.getMonth() + (length > 0 ? 1 : -1));
+  const timeframe = getTimeframeWindowFrom(startDate, length);
+
+  return getTimeframeForQuartersView(startDate, length > 0 ? timeframe : timeframe.reverse());
+};
+
 /**
  * This method returns array of Dates respresenting Months based on provided initialDate
  *
  * For eg; If initialDate is 15th Jan 2018
  *         Then as per Roadmap specs, we need to show
- *         1 month before current month AND
+ *         2 months before current month,
+ *         current month AND
  *         5 months after current month
- *         thus, total of 7 months.
+ *         thus, total of 8 months.
  *
  * So returned array from this method will be;
  *        [
- *          1 Dec 2017, 1 Jan 2018, 1 Feb 2018, 1 Mar 2018,
- *          1 Apr 2018, 1 May 2018, 30 Jun 2018
+ *          1 Nov 2017, 1 Dec 2017, 1 Jan 2018, 1 Feb 2018,
+ *          1 Mar 2018, 1 Apr 2018, 1 May 2018, 30 Jun 2018
  *        ]
  *
  * @param {Date} initialDate
  */
 export const getTimeframeForMonthsView = (initialDate = new Date()) => {
-  const startDate = initialDate;
-  startDate.setHours(0, 0, 0, 0);
+  const startDate = newDate(initialDate);
 
   // Move startDate to a month prior to current month
-  startDate.setMonth(startDate.getMonth() - 1);
+  startDate.setMonth(startDate.getMonth() - 2);
 
   return getTimeframeWindowFrom(startDate, PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH);
 };
 
+export const extendTimeframeForMonthsView = (initialDate = new Date(), length) => {
+  const startDate = newDate(initialDate);
+
+  // When length is positive (which means extension is of type APPEND)
+  // Set initial date as first day of the month.
+  if (length > 0) {
+    startDate.setDate(1);
+  }
+
+  const timeframe = getTimeframeWindowFrom(startDate, length - 1).slice(1);
+
+  return length > 0 ? timeframe : timeframe.reverse();
+};
+
 /**
  * This method returns array of Dates respresenting Months based on provided initialDate
  *
  * For eg; If initialDate is 15th Jan 2018
  *         Then as per Roadmap specs, we need to show
- *         1 week before current week AND
+ *         2 weeks before current week,
+ *         current week AND
  *         4 weeks after current week
- *         thus, total of 6 weeks.
+ *         thus, total of 7 weeks.
  *         Note that week starts on Sunday
  *
  * So returned array from this method will be;
  *        [
- *          7 Jan 2018, 14 Jan 2018, 21 Jan 2018,
+ *          31 Dec 2017, 7 Jan 2018, 14 Jan 2018, 21 Jan 2018,
  *          28 Jan 2018, 4 Mar 2018, 11 Mar 2018
  *        ]
  *
  * @param {Date} initialDate
  */
 export const getTimeframeForWeeksView = (initialDate = new Date()) => {
-  const startDate = initialDate;
+  const startDate = newDate(initialDate);
   startDate.setHours(0, 0, 0, 0);
 
   const dayOfWeek = startDate.getDay();
-  const daysToFirstDayOfPrevWeek = dayOfWeek + 7;
+  const daysToFirstDayOfPrevWeek = dayOfWeek + 14;
   const timeframe = [];
 
-  // Move startDate to first day (Sunday) of previous week
+  // Move startDate to first day (Sunday) of 2 weeks prior
   startDate.setDate(startDate.getDate() - daysToFirstDayOfPrevWeek);
 
   // Iterate for the length of this preset
   for (let i = 0; i < PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH; i += 1) {
     // Push date to timeframe only when day is
-    // first day (Sunday) of the week1
-    if (startDate.getDay() === 0) {
-      timeframe.push(new Date(startDate.getTime()));
-    }
-    // Move date one day further
-    startDate.setDate(startDate.getDate() + 1);
+    // first day (Sunday) of the week
+    timeframe.push(newDate(startDate));
+
+    // Move date next Sunday
+    startDate.setDate(startDate.getDate() + 7);
   }
 
   return timeframe;
 };
 
-export const getTimeframeForPreset = (presetType = PRESET_TYPES.MONTHS) => {
+export const extendTimeframeForWeeksView = (initialDate = new Date(), length) => {
+  const startDate = newDate(initialDate);
+
+  if (length < 0) {
+    startDate.setDate(startDate.getDate() + (length + 1) * 7);
+  } else {
+    startDate.setDate(startDate.getDate() + 7);
+  }
+
+  const timeframe = getTimeframeForWeeksView(startDate, length + 1);
+  return timeframe.slice(1);
+};
+
+export const extendTimeframeForPreset = ({
+  presetType = PRESET_TYPES.MONTHS,
+  extendAs = EXTEND_AS.PREPEND,
+  extendByLength = 0,
+  initialDate,
+}) => {
   if (presetType === PRESET_TYPES.QUARTERS) {
-    return getTimeframeForQuartersView();
+    const length = extendByLength || PRESET_DEFAULTS.QUARTERS.TIMEFRAME_LENGTH;
+
+    return extendTimeframeForQuartersView(
+      initialDate,
+      extendAs === EXTEND_AS.PREPEND ? -length : length,
+    );
   } else if (presetType === PRESET_TYPES.MONTHS) {
-    return getTimeframeForMonthsView();
+    const length = extendByLength || PRESET_DEFAULTS.MONTHS.TIMEFRAME_LENGTH;
+
+    return extendTimeframeForMonthsView(
+      initialDate,
+      extendAs === EXTEND_AS.PREPEND ? -length : length,
+    );
   }
-  return getTimeframeForWeeksView();
+
+  const length = extendByLength || PRESET_DEFAULTS.WEEKS.TIMEFRAME_LENGTH;
+
+  return extendTimeframeForWeeksView(
+    initialDate,
+    extendAs === EXTEND_AS.PREPEND ? -length : length,
+  );
+};
+
+export const extendTimeframeForAvailableWidth = ({
+  timeframe,
+  timeframeStart,
+  timeframeEnd,
+  availableTimeframeWidth,
+  presetType,
+}) => {
+  let timeframeLength = timeframe.length;
+
+  // Estimate how many more timeframe columns are needed
+  // to fill in extra screen space so that timeline becomes
+  // horizontally scrollable.
+  while (availableTimeframeWidth / timeframeLength > TIMELINE_CELL_MIN_WIDTH) {
+    timeframeLength += 1;
+  }
+  // We double the increaseLengthBy to make sure there's enough room
+  // to perform horizontal scroll without triggering timeframe extension
+  // on initial page load.
+  const increaseLengthBy = (timeframeLength - timeframe.length) * 2;
+
+  // If there are timeframe items to be added
+  // to make timeline scrollable, do as follows.
+  if (increaseLengthBy > 0) {
+    // Split length in 2 parts and get
+    // count for both prepend and append.
+    const prependBy = Math.floor(increaseLengthBy / 2);
+    const appendBy = Math.ceil(increaseLengthBy / 2);
+
+    if (prependBy) {
+      // Prepend the timeline with
+      // the count as given by prependBy
+      timeframe.unshift(
+        ...extendTimeframeForPreset({
+          extendAs: EXTEND_AS.PREPEND,
+          initialDate: timeframeStart,
+          // In case of presetType `quarters`, length would represent
+          // number of months for total quarters, hence we do `* 3`.
+          extendByLength: presetType === PRESET_TYPES.QUARTERS ? prependBy * 3 : prependBy,
+          presetType,
+        }),
+      );
+    }
+
+    if (appendBy) {
+      // Append the timeline with
+      // the count as given by appendBy
+      timeframe.push(
+        ...extendTimeframeForPreset({
+          extendAs: EXTEND_AS.APPEND,
+          initialDate: timeframeEnd,
+          // In case of presetType `quarters`, length would represent
+          // number of months for total quarters, hence we do `* 3`.
+          //
+          // For other preset types, we add `2` to appendBy to compensate for
+          // last item of original timeframe (month or week)
+          extendByLength: presetType === PRESET_TYPES.QUARTERS ? appendBy * 3 : appendBy + 2,
+          presetType,
+        }),
+      );
+    }
+  }
+};
+
+export const getTimeframeForPreset = (
+  presetType = PRESET_TYPES.MONTHS,
+  availableTimeframeWidth = 0,
+) => {
+  let timeframe;
+  let timeframeStart;
+  let timeframeEnd;
+
+  // Get timeframe based on presetType and
+  // extract timeframeStart and timeframeEnd
+  // date objects
+  if (presetType === PRESET_TYPES.QUARTERS) {
+    timeframe = getTimeframeForQuartersView();
+    [timeframeStart] = timeframe[0].range;
+    // eslint-disable-next-line prefer-destructuring
+    timeframeEnd = timeframe[timeframe.length - 1].range[2];
+  } else if (presetType === PRESET_TYPES.MONTHS) {
+    timeframe = getTimeframeForMonthsView();
+    [timeframeStart] = timeframe;
+    timeframeEnd = timeframe[timeframe.length - 1];
+  } else {
+    timeframe = getTimeframeForWeeksView();
+    timeframeStart = newDate(timeframe[0]);
+    timeframeEnd = newDate(timeframe[timeframe.length - 1]);
+    timeframeStart.setDate(timeframeStart.getDate() - 7); // Move date back by a week
+    timeframeEnd.setDate(timeframeEnd.getDate() + 7); // Move date ahead by a week
+  }
+
+  // Extend timeframe on initial load to ensure
+  // timeline is horizontally scrollable in all
+  // screen sizes.
+  extendTimeframeForAvailableWidth({
+    timeframe,
+    timeframeStart,
+    timeframeEnd,
+    availableTimeframeWidth,
+    presetType,
+  });
+
+  return timeframe;
 };
 
 export const getEpicsPathForPreset = ({
@@ -184,7 +350,7 @@ export const getEpicsPathForPreset = ({
   filterQueryString = '',
   presetType = '',
   timeframe = [],
-  state = 'all',
+  epicsState = 'all',
 }) => {
   let start;
   let end;
@@ -208,13 +374,13 @@ export const getEpicsPathForPreset = ({
     end = lastTimeframe;
   } else if (presetType === PRESET_TYPES.WEEKS) {
     start = firstTimeframe;
-    end = new Date(lastTimeframe.getTime());
+    end = newDate(lastTimeframe);
     end.setDate(end.getDate() + 6);
   }
 
   const startDate = `${start.getFullYear()}-${start.getMonth() + 1}-${start.getDate()}`;
   const endDate = `${end.getFullYear()}-${end.getMonth() + 1}-${end.getDate()}`;
-  epicsPath += `?state=${state}&start_date=${startDate}&end_date=${endDate}`;
+  epicsPath += `?state=${epicsState}&start_date=${startDate}&end_date=${endDate}`;
 
   if (filterQueryString) {
     epicsPath += `&${filterQueryString}`;
@@ -222,3 +388,43 @@ export const getEpicsPathForPreset = ({
 
   return epicsPath;
 };
+
+export const sortEpics = (epics, sortedBy) => {
+  const sortByStartDate = sortedBy.indexOf('start_date') > -1;
+  const sortOrderAsc = sortedBy.indexOf('asc') > -1;
+
+  epics.sort((a, b) => {
+    let aDate;
+    let bDate;
+
+    if (sortByStartDate) {
+      aDate = a.startDate;
+      if (a.startDateOutOfRange) {
+        aDate = a.originalStartDate;
+      }
+
+      bDate = b.startDate;
+      if (b.startDateOutOfRange) {
+        bDate = b.originalStartDate;
+      }
+    } else {
+      aDate = a.endDate;
+      if (a.endDateOutOfRange) {
+        aDate = a.originalEndDate;
+      }
+
+      bDate = b.endDate;
+      if (b.endDateOutOfRange) {
+        bDate = b.originalEndDate;
+      }
+    }
+
+    // Sort in ascending or descending order
+    if (aDate.getTime() < bDate.getTime()) {
+      return sortOrderAsc ? -1 : 1;
+    } else if (aDate.getTime() > bDate.getTime()) {
+      return sortOrderAsc ? 1 : -1;
+    }
+    return 0;
+  });
+};
diff --git a/ee/app/assets/stylesheets/pages/roadmap.scss b/ee/app/assets/stylesheets/pages/roadmap.scss
index b5d2b6196722..b23766fcf742 100644
--- a/ee/app/assets/stylesheets/pages/roadmap.scss
+++ b/ee/app/assets/stylesheets/pages/roadmap.scss
@@ -20,6 +20,36 @@ $column-right-gradient: linear-gradient(
   $roadmap-gradient-gray 100%
 );
 
+@keyframes colorTransitionDetailsCell {
+  from {
+    background-color: $blue-100;
+  }
+
+  to {
+    background-color: $white-light;
+  }
+}
+
+@keyframes fadeInDetails {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+@keyframes fadeinTimelineBar {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 0.75;
+  }
+}
+
 @mixin roadmap-scroll-mixin {
   height: $grid-size;
   width: $details-cell-width;
@@ -229,6 +259,12 @@ $column-right-gradient: linear-gradient(
       }
     }
 
+    &.newly-added-epic {
+      .epic-details-cell {
+        animation: colorTransitionDetailsCell 3s;
+      }
+    }
+
     .epic-details-cell,
     .epic-timeline-cell {
       box-sizing: border-box;
@@ -251,6 +287,11 @@ $column-right-gradient: linear-gradient(
         height: $item-height;
       }
 
+      .epic-title,
+      .epic-group-timeframe {
+        animation: fadeInDetails 1s;
+      }
+
       .epic-title {
         display: table;
         table-layout: fixed;
@@ -294,25 +335,12 @@ $column-right-gradient: linear-gradient(
         background-color: $blue-500;
         border-radius: $border-radius-default;
         opacity: 0.75;
+        animation: fadeinTimelineBar 1s;
 
         &:hover {
           opacity: 1;
         }
 
-        &.start-date-outside::before,
-        &.end-date-outside::after {
-          content: "";
-          position: absolute;
-          top: 0;
-          height: 100%;
-        }
-
-        &.start-date-outside::before,
-        &.end-date-outside::after {
-          border-top: 12px solid transparent;
-          border-bottom: 12px solid transparent;
-        }
-
         &.start-date-undefined {
           background: linear-gradient(
             to right,
@@ -330,31 +358,6 @@ $column-right-gradient: linear-gradient(
             $roadmap-gradient-gray 100%
           );
         }
-
-        &.start-date-outside {
-          border-top-left-radius: 0;
-          border-bottom-left-radius: 0;
-
-          &::before {
-            left: -$grid-size;
-            border-right: $grid-size solid $blue-500;
-          }
-        }
-
-        &.end-date-outside {
-          border-top-right-radius: 0;
-          border-bottom-right-radius: 0;
-
-          &::after {
-            right: -$grid-size;
-            border-left: $grid-size solid $blue-500;
-          }
-        }
-
-        &.start-date-outside,
-        &.start-date-undefined.end-date-outside {
-          left: $grid-size;
-        }
       }
 
       &:last-child {
diff --git a/ee/spec/javascripts/roadmap/components/app_spec.js b/ee/spec/javascripts/roadmap/components/app_spec.js
index fa55917a9d6b..d8710cc6c0d0 100644
--- a/ee/spec/javascripts/roadmap/components/app_spec.js
+++ b/ee/spec/javascripts/roadmap/components/app_spec.js
@@ -6,25 +6,42 @@ import axios from '~/lib/utils/axios_utils';
 import appComponent from 'ee/roadmap/components/app.vue';
 import RoadmapStore from 'ee/roadmap/store/roadmap_store';
 import RoadmapService from 'ee/roadmap/service/roadmap_service';
+import eventHub from 'ee/roadmap/event_hub';
 
-import { PRESET_TYPES } from 'ee/roadmap/constants';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
+import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
 import {
-  mockTimeframeMonths,
+  mockTimeframeInitialDate,
   mockGroupId,
+  basePath,
   epicsPath,
   mockNewEpicEndpoint,
   rawEpics,
   mockSvgPath,
+  mockSortedBy,
 } from '../mock_data';
 
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
+
 const createComponent = () => {
   const Component = Vue.extend(appComponent);
   const timeframe = mockTimeframeMonths;
 
-  const store = new RoadmapStore(mockGroupId, timeframe);
-  const service = new RoadmapService(epicsPath);
+  const store = new RoadmapStore({
+    groupId: mockGroupId,
+    presetType: PRESET_TYPES.MONTHS,
+    sortedBy: mockSortedBy,
+    timeframe,
+  });
+
+  const service = new RoadmapService({
+    initialEpicsPath: epicsPath,
+    epicsState: 'all',
+    basePath,
+  });
 
   return mountComponent(Component, {
     store,
@@ -173,6 +190,193 @@ describe('AppComponent', () => {
         }, 0);
       });
     });
+
+    describe('fetchEpicsForTimeframe', () => {
+      const roadmapTimelineEl = {
+        offsetTop: 0,
+      };
+      let mock;
+
+      beforeEach(() => {
+        mock = new MockAdapter(axios);
+        document.body.innerHTML += '<div class="flash-container"></div>';
+      });
+
+      afterEach(() => {
+        mock.restore();
+        document.querySelector('.flash-container').remove();
+      });
+
+      it('calls service.fetchEpicsForTimeframe and adds response to the store on success', done => {
+        mock.onGet(vm.service.epicsPath).reply(200, rawEpics);
+
+        spyOn(vm.service, 'getEpicsForTimeframe').and.callThrough();
+        spyOn(vm.store, 'addEpics');
+        spyOn(vm, '$nextTick').and.stub();
+
+        vm.fetchEpicsForTimeframe({
+          timeframe: mockTimeframeMonths,
+          extendType: EXTEND_AS.APPEND,
+          roadmapTimelineEl,
+        });
+
+        expect(vm.hasError).toBe(false);
+        expect(vm.service.getEpicsForTimeframe).toHaveBeenCalledWith(
+          PRESET_TYPES.MONTHS,
+          mockTimeframeMonths,
+        );
+        setTimeout(() => {
+          expect(vm.store.addEpics).toHaveBeenCalledWith(rawEpics);
+          done();
+        }, 0);
+      });
+
+      it('calls service.fetchEpicsForTimeframe and sets `hasError` to true and shows flash message when request failed', done => {
+        mock.onGet(vm.service.fetchEpicsForTimeframe).reply(500, {});
+
+        vm.fetchEpicsForTimeframe({
+          timeframe: mockTimeframeMonths,
+          extendType: EXTEND_AS.APPEND,
+          roadmapTimelineEl,
+        });
+
+        expect(vm.hasError).toBe(false);
+        setTimeout(() => {
+          expect(vm.hasError).toBe(true);
+          expect(document.querySelector('.flash-text').innerText.trim()).toBe(
+            'Something went wrong while fetching epics',
+          );
+          done();
+        }, 0);
+      });
+    });
+
+    describe('processExtendedTimeline', () => {
+      it('updates timeline by extending timeframe from the start when called with extendType as `prepend`', done => {
+        vm.store.setEpics(rawEpics);
+        vm.isLoading = false;
+
+        Vue.nextTick()
+          .then(() => {
+            const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
+
+            spyOn(eventHub, '$emit');
+            spyOn(roadmapTimelineEl.parentElement, 'scrollBy');
+
+            vm.processExtendedTimeline({
+              extendType: EXTEND_AS.PREPEND,
+              roadmapTimelineEl,
+              itemsCount: 0,
+            });
+
+            expect(eventHub.$emit).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Object));
+            expect(roadmapTimelineEl.parentElement.scrollBy).toHaveBeenCalled();
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+
+      it('updates timeline by extending timeframe from the end when called with extendType as `append`', done => {
+        vm.store.setEpics(rawEpics);
+        vm.isLoading = false;
+
+        Vue.nextTick()
+          .then(() => {
+            const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
+
+            spyOn(eventHub, '$emit');
+
+            vm.processExtendedTimeline({
+              extendType: EXTEND_AS.PREPEND,
+              roadmapTimelineEl,
+              itemsCount: 0,
+            });
+
+            expect(eventHub.$emit).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Object));
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+    });
+
+    describe('handleScrollToExtend', () => {
+      beforeEach(() => {
+        vm.store.setEpics(rawEpics);
+        vm.isLoading = false;
+      });
+
+      it('updates the store and refreshes roadmap with extended timeline when called with `extendType` param as `prepend`', done => {
+        spyOn(vm.store, 'extendTimeframe');
+        spyOn(vm, 'processExtendedTimeline');
+        spyOn(vm, 'fetchEpicsForTimeframe');
+
+        const extendType = EXTEND_AS.PREPEND;
+        const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
+
+        vm.handleScrollToExtend(roadmapTimelineEl, extendType);
+
+        expect(vm.store.extendTimeframe).toHaveBeenCalledWith(extendType);
+
+        vm.$nextTick()
+          .then(() => {
+            expect(vm.processExtendedTimeline).toHaveBeenCalledWith(
+              jasmine.objectContaining({
+                itemsCount: 0,
+                extendType,
+                roadmapTimelineEl,
+              }),
+            );
+
+            expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
+              jasmine.objectContaining({
+                // During tests, we don't extend timeframe
+                // as we spied on `vm.store.extendTimeframe` above
+                timeframe: undefined,
+                roadmapTimelineEl,
+                extendType,
+              }),
+            );
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+
+      it('updates the store and refreshes roadmap with extended timeline when called with `extendType` param as `append`', done => {
+        spyOn(vm.store, 'extendTimeframe');
+        spyOn(vm, 'processExtendedTimeline');
+        spyOn(vm, 'fetchEpicsForTimeframe');
+
+        const extendType = EXTEND_AS.APPEND;
+        const roadmapTimelineEl = vm.$el.querySelector('.roadmap-timeline-section');
+
+        vm.handleScrollToExtend(roadmapTimelineEl, extendType);
+
+        expect(vm.store.extendTimeframe).toHaveBeenCalledWith(extendType);
+
+        vm.$nextTick()
+          .then(() => {
+            expect(vm.processExtendedTimeline).toHaveBeenCalledWith(
+              jasmine.objectContaining({
+                itemsCount: 0,
+                extendType,
+                roadmapTimelineEl,
+              }),
+            );
+
+            expect(vm.fetchEpicsForTimeframe).toHaveBeenCalledWith(
+              jasmine.objectContaining({
+                // During tests, we don't extend timeframe
+                // as we spied on `vm.store.extendTimeframe` above
+                timeframe: undefined,
+                roadmapTimelineEl,
+                extendType,
+              }),
+            );
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+    });
   });
 
   describe('mounted', () => {
diff --git a/ee/spec/javascripts/roadmap/components/epic_item_spec.js b/ee/spec/javascripts/roadmap/components/epic_item_spec.js
index c8a6796f77d0..add5b13d8885 100644
--- a/ee/spec/javascripts/roadmap/components/epic_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/epic_item_spec.js
@@ -1,18 +1,24 @@
 import Vue from 'vue';
 
+import _ from 'underscore';
+
 import epicItemComponent from 'ee/roadmap/components/epic_item.vue';
 
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
 import {
-  mockTimeframeMonths,
+  mockTimeframeInitialDate,
   mockEpic,
   mockGroupId,
   mockShellWidth,
   mockItemWidth,
 } from '../mock_data';
 
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
+
 const createComponent = ({
   presetType = PRESET_TYPES.MONTHS,
   epic = mockEpic,
@@ -44,6 +50,25 @@ describe('EpicItemComponent', () => {
     vm.$destroy();
   });
 
+  describe('methods', () => {
+    describe('removeHighlight', () => {
+      it('should call _.delay after 3 seconds with a callback function which would set `epic.newEpic` to false when it is true already', done => {
+        spyOn(_, 'delay');
+
+        vm.epic.newEpic = true;
+
+        vm.removeHighlight();
+
+        vm.$nextTick()
+          .then(() => {
+            expect(_.delay).toHaveBeenCalledWith(jasmine.any(Function), 3000);
+          })
+          .then(done)
+          .catch(done.fail);
+      });
+    });
+  });
+
   describe('template', () => {
     it('renders component container element class `epics-list-item`', () => {
       expect(vm.$el.classList.contains('epics-list-item')).toBeTruthy();
diff --git a/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js b/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js
index 0f16e7b6596a..f85274420c20 100644
--- a/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js
+++ b/ee/spec/javascripts/roadmap/components/epic_item_timeline_spec.js
@@ -1,15 +1,15 @@
 import Vue from 'vue';
 
 import epicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
-import {
-  TIMELINE_END_OFFSET_FULL,
-  TIMELINE_END_OFFSET_HALF,
-  TIMELINE_CELL_MIN_WIDTH,
-  PRESET_TYPES,
-} from 'ee/roadmap/constants';
+import eventHub from 'ee/roadmap/event_hub';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
+import { TIMELINE_CELL_MIN_WIDTH, PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeMonths, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.MONTHS,
@@ -62,7 +62,7 @@ describe('EpicItemTimelineComponent', () => {
       it('returns proportionate width based on timeframe length and shellWidth', () => {
         vm = createComponent({});
 
-        expect(vm.getCellWidth()).toBe(240);
+        expect(vm.getCellWidth()).toBe(210);
       });
 
       it('returns minimum fixed width when proportionate width available lower than minimum fixed width defined', () => {
@@ -74,46 +74,6 @@ describe('EpicItemTimelineComponent', () => {
       });
     });
 
-    describe('getTimelineBarEndOffset', () => {
-      it('returns full offset value when both Epic startDate and endDate is out of range', () => {
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            startDateOutOfRange: true,
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
-      });
-
-      it('returns full offset value when Epic startDate is undefined and endDate is out of range', () => {
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            startDateUndefined: true,
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_FULL);
-      });
-
-      it('returns half offset value when Epic endDate is out of range', () => {
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarEndOffset()).toBe(TIMELINE_END_OFFSET_HALF);
-      });
-
-      it('returns 0 when both Epic startDate and endDate is defined and within range', () => {
-        vm = createComponent({});
-
-        expect(vm.getTimelineBarEndOffset()).toBe(0);
-      });
-    });
-
     describe('renderTimelineBar', () => {
       it('sets `timelineBarStyles` & `timelineBarReady` when timeframeItem has Epic.startDate', () => {
         vm = createComponent({
@@ -122,7 +82,7 @@ describe('EpicItemTimelineComponent', () => {
         });
         vm.renderTimelineBar();
 
-        expect(vm.timelineBarStyles).toBe('width: 1216px; left: 0;');
+        expect(vm.timelineBarStyles).toBe('width: 1274px; left: 0;');
         expect(vm.timelineBarReady).toBe(true);
       });
 
@@ -139,6 +99,26 @@ describe('EpicItemTimelineComponent', () => {
     });
   });
 
+  describe('mounted', () => {
+    it('binds `refreshTimeline` event listener on eventHub', () => {
+      spyOn(eventHub, '$on');
+      const vmX = createComponent({});
+
+      expect(eventHub.$on).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
+      vmX.$destroy();
+    });
+  });
+
+  describe('beforeDestroy', () => {
+    it('unbinds `refreshTimeline` event listener on eventHub', () => {
+      spyOn(eventHub, '$off');
+      const vmX = createComponent({});
+      vmX.$destroy();
+
+      expect(eventHub.$off).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
+    });
+  });
+
   describe('template', () => {
     it('renders component container element with class `epic-timeline-cell`', () => {
       vm = createComponent({});
@@ -172,7 +152,7 @@ describe('EpicItemTimelineComponent', () => {
 
       vm.renderTimelineBar();
       vm.$nextTick(() => {
-        expect(timelineBarEl.getAttribute('style')).toBe('width: 608.571px; left: 0px;');
+        expect(timelineBarEl.getAttribute('style')).toBe('width: 742.5px; left: 0px;');
         done();
       });
     });
@@ -193,23 +173,6 @@ describe('EpicItemTimelineComponent', () => {
       });
     });
 
-    it('renders timeline bar with `start-date-outside` class when Epic startDate is out of range of timeframe', done => {
-      vm = createComponent({
-        epic: Object.assign({}, mockEpic, {
-          startDateOutOfRange: true,
-          startDate: mockTimeframeMonths[0],
-          originalStartDate: new Date(2017, 0, 1),
-        }),
-      });
-      const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
-
-      vm.renderTimelineBar();
-      vm.$nextTick(() => {
-        expect(timelineBarEl.classList.contains('start-date-outside')).toBe(true);
-        done();
-      });
-    });
-
     it('renders timeline bar with `end-date-undefined` class when Epic endDate is undefined', done => {
       vm = createComponent({
         epic: Object.assign({}, mockEpic, {
@@ -226,23 +189,5 @@ describe('EpicItemTimelineComponent', () => {
         done();
       });
     });
-
-    it('renders timeline bar with `end-date-outside` class when Epic endDate is out of range of timeframe', done => {
-      vm = createComponent({
-        epic: Object.assign({}, mockEpic, {
-          startDate: mockTimeframeMonths[0],
-          endDateOutOfRange: true,
-          endDate: mockTimeframeMonths[mockTimeframeMonths.length - 1],
-          originalEndDate: new Date(2018, 11, 1),
-        }),
-      });
-      const timelineBarEl = vm.$el.querySelector('.timeline-bar-wrapper .timeline-bar');
-
-      vm.renderTimelineBar();
-      vm.$nextTick(() => {
-        expect(timelineBarEl.classList.contains('end-date-outside')).toBe(true);
-        done();
-      });
-    });
   });
 });
diff --git a/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js b/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js
index 46ad26376853..ddbb39f08966 100644
--- a/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js
+++ b/ee/spec/javascripts/roadmap/components/epics_list_empty_spec.js
@@ -2,16 +2,20 @@ import Vue from 'vue';
 
 import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.vue';
 
+import {
+  getTimeframeForQuartersView,
+  getTimeframeForWeeksView,
+  getTimeframeForMonthsView,
+} from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import {
-  mockTimeframeQuarters,
-  mockTimeframeMonths,
-  mockTimeframeWeeks,
-  mockSvgPath,
-  mockNewEpicEndpoint,
-} from '../mock_data';
+import { mockTimeframeInitialDate, mockSvgPath, mockNewEpicEndpoint } from '../mock_data';
+
+const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
+const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
 
 const createComponent = ({
   hasFiltersApplied = false,
@@ -71,7 +75,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from Oct 1, 2017 to Mar 31, 2019.',
+                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Jul 1, 2017 to Mar 31, 2019.',
               );
             })
             .then(done)
@@ -83,7 +87,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from Oct 1, 2017 to Mar 31, 2019.',
+                'To widen your search, change or remove filters; from Jul 1, 2017 to Mar 31, 2019.',
               );
             })
             .then(done)
@@ -100,7 +104,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from Dec 1, 2017 to Jun 30, 2018.',
+                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Nov 1, 2017 to Jun 30, 2018.',
               );
             })
             .then(done)
@@ -112,7 +116,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from Dec 1, 2017 to Jun 30, 2018.',
+                'To widen your search, change or remove filters; from Nov 1, 2017 to Jun 30, 2018.',
               );
             })
             .then(done)
@@ -134,7 +138,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from Dec 24, 2017 to Feb 9, 2018.',
+                'To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from Dec 17, 2017 to Feb 9, 2018.',
               );
             })
             .then(done)
@@ -146,7 +150,7 @@ describe('EpicsListEmptyComponent', () => {
           Vue.nextTick()
             .then(() => {
               expect(vm.subMessage).toBe(
-                'To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from Dec 24, 2017 to Feb 15, 2018.',
+                'To widen your search, change or remove filters; from Dec 17, 2017 to Feb 15, 2018.',
               );
             })
             .then(done)
@@ -157,7 +161,7 @@ describe('EpicsListEmptyComponent', () => {
 
     describe('timeframeRange', () => {
       it('returns correct timeframe startDate and endDate in words', () => {
-        expect(vm.timeframeRange.startDate).toBe('Dec 1, 2017');
+        expect(vm.timeframeRange.startDate).toBe('Nov 1, 2017');
         expect(vm.timeframeRange.endDate).toBe('Jun 30, 2018');
       });
     });
diff --git a/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js b/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js
index 1a2b273f3cae..cb37021dd8aa 100644
--- a/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js
+++ b/ee/spec/javascripts/roadmap/components/epics_list_section_spec.js
@@ -3,11 +3,26 @@ import Vue from 'vue';
 import epicsListSectionComponent from 'ee/roadmap/components/epics_list_section.vue';
 import RoadmapStore from 'ee/roadmap/store/roadmap_store';
 import eventHub from 'ee/roadmap/event_hub';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { rawEpics, mockTimeframeMonths, mockGroupId, mockShellWidth } from '../mock_data';
+import {
+  rawEpics,
+  mockTimeframeInitialDate,
+  mockGroupId,
+  mockShellWidth,
+  mockSortedBy,
+} from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
+
+const store = new RoadmapStore({
+  groupId: mockGroupId,
+  presetType: PRESET_TYPES.MONTHS,
+  sortedBy: mockSortedBy,
+  timeframe: mockTimeframeMonths,
+});
 
-const store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
 store.setEpics(rawEpics);
 const mockEpics = store.getEpics();
 
@@ -63,7 +78,7 @@ describe('EpicsListSectionComponent', () => {
 
     describe('emptyRowCellStyles', () => {
       it('returns computed style object based on sectionItemWidth prop value', () => {
-        expect(vm.emptyRowCellStyles.width).toBe('240px');
+        expect(vm.emptyRowCellStyles.width).toBe('210px');
       });
     });
 
@@ -129,15 +144,6 @@ describe('EpicsListSectionComponent', () => {
         });
       });
     });
-
-    describe('scrollToTodayIndicator', () => {
-      it('scrolls table body to put timeline today indicator in focus', () => {
-        spyOn(vm.$el, 'scrollTo');
-        vm.scrollToTodayIndicator();
-
-        expect(vm.$el.scrollTo).toHaveBeenCalledWith(jasmine.any(Number), 0);
-      });
-    });
   });
 
   describe('template', () => {
diff --git a/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js
index 523a8ae5ec96..3d478d65ae76 100644
--- a/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_months/months_header_item_spec.js
@@ -1,10 +1,12 @@
 import Vue from 'vue';
 
 import MonthsHeaderItemComponent from 'ee/roadmap/components/preset_months/months_header_item.vue';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeMonths, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
 
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 const mockTimeframeIndex = 0;
 
 const createComponent = ({
@@ -56,7 +58,7 @@ describe('MonthsHeaderItemComponent', () => {
       it('returns string containing Year and Month for current timeline header item', () => {
         vm = createComponent({});
 
-        expect(vm.timelineHeaderLabel).toBe('2017 Dec');
+        expect(vm.timelineHeaderLabel).toBe('2017 Nov');
       });
 
       it('returns string containing only Month for current timeline header item when previous header contained Year', () => {
@@ -65,7 +67,7 @@ describe('MonthsHeaderItemComponent', () => {
           timeframeItem: mockTimeframeMonths[mockTimeframeIndex + 1],
         });
 
-        expect(vm.timelineHeaderLabel).toBe('2018 Jan');
+        expect(vm.timelineHeaderLabel).toBe('Dec');
       });
     });
 
@@ -117,7 +119,7 @@ describe('MonthsHeaderItemComponent', () => {
       const itemLabelEl = vm.$el.querySelector('.item-label');
 
       expect(itemLabelEl).not.toBeNull();
-      expect(itemLabelEl.innerText.trim()).toBe('2017 Dec');
+      expect(itemLabelEl.innerText.trim()).toBe('2017 Nov');
     });
   });
 });
diff --git a/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js
index 7e62c1aa56bf..cdd9bf04727e 100644
--- a/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_months/months_header_sub_item_spec.js
@@ -1,9 +1,12 @@
 import Vue from 'vue';
 
 import MonthsHeaderSubItemComponent from 'ee/roadmap/components/preset_months/months_header_sub_item.vue';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeMonths } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = ({
   currentDate = mockTimeframeMonths[0],
diff --git a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js
index 93b6364ffb05..611a02b9b2d1 100644
--- a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_item_spec.js
@@ -1,11 +1,13 @@
 import Vue from 'vue';
 
 import QuartersHeaderItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_item.vue';
+import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeQuarters, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
 
 const mockTimeframeIndex = 0;
+const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
 
 const createComponent = ({
   timeframeIndex = mockTimeframeIndex,
@@ -38,8 +40,6 @@ describe('QuartersHeaderItemComponent', () => {
       const currentDate = new Date();
 
       expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
-      expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[0]);
-      expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[2]);
     });
   });
 
@@ -52,11 +52,23 @@ describe('QuartersHeaderItemComponent', () => {
       });
     });
 
+    describe('quarterBeginDate', () => {
+      it('returns date object representing quarter begin date for current `timeframeItem`', () => {
+        expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[0]);
+      });
+    });
+
+    describe('quarterEndDate', () => {
+      it('returns date object representing quarter end date for current `timeframeItem`', () => {
+        expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[mockTimeframeIndex].range[2]);
+      });
+    });
+
     describe('timelineHeaderLabel', () => {
       it('returns string containing Year and Quarter for current timeline header item', () => {
         vm = createComponent({});
 
-        expect(vm.timelineHeaderLabel).toBe('2017 Q4');
+        expect(vm.timelineHeaderLabel).toBe('2017 Q3');
       });
 
       it('returns string containing only Quarter for current timeline header item when previous header contained Year', () => {
@@ -65,7 +77,7 @@ describe('QuartersHeaderItemComponent', () => {
           timeframeItem: mockTimeframeQuarters[mockTimeframeIndex + 2],
         });
 
-        expect(vm.timelineHeaderLabel).toBe('Q2');
+        expect(vm.timelineHeaderLabel).toBe('2018 Q1');
       });
     });
 
@@ -118,7 +130,7 @@ describe('QuartersHeaderItemComponent', () => {
       const itemLabelEl = vm.$el.querySelector('.item-label');
 
       expect(itemLabelEl).not.toBeNull();
-      expect(itemLabelEl.innerText.trim()).toBe('2017 Q4');
+      expect(itemLabelEl.innerText.trim()).toBe('2017 Q3');
     });
   });
 });
diff --git a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js
index 12f84e12b8c9..69bf9d38726a 100644
--- a/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_quarters/quarters_header_sub_item_spec.js
@@ -1,9 +1,12 @@
 import Vue from 'vue';
 
 import QuartersHeaderSubItemComponent from 'ee/roadmap/components/preset_quarters/quarters_header_sub_item.vue';
+import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeQuarters } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
+
+const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
 
 const createComponent = ({
   currentDate = mockTimeframeQuarters[0].range[1],
@@ -25,6 +28,22 @@ describe('QuartersHeaderSubItemComponent', () => {
   });
 
   describe('computed', () => {
+    describe('quarterBeginDate', () => {
+      it('returns first month from the `timeframeItem.range`', () => {
+        vm = createComponent({});
+
+        expect(vm.quarterBeginDate).toBe(mockTimeframeQuarters[0].range[0]);
+      });
+    });
+
+    describe('quarterEndDate', () => {
+      it('returns first month from the `timeframeItem.range`', () => {
+        vm = createComponent({});
+
+        expect(vm.quarterEndDate).toBe(mockTimeframeQuarters[0].range[2]);
+      });
+    });
+
     describe('headerSubItems', () => {
       it('returns array of dates containing Months from timeframeItem', () => {
         vm = createComponent({});
@@ -46,7 +65,7 @@ describe('QuartersHeaderSubItemComponent', () => {
       it('returns false when current quarter month is different from timeframe quarter', () => {
         vm = createComponent({
           currentDate: new Date(2017, 10, 1), // Nov 1, 2017
-          timeframeItem: mockTimeframeQuarters[1], // 2018 Apr May Jun
+          timeframeItem: mockTimeframeQuarters[0], // 2018 Apr May Jun
         });
 
         expect(vm.hasToday).toBe(false);
diff --git a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js
index 26a5c1e40e13..1177534b0973 100644
--- a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_item_spec.js
@@ -1,11 +1,13 @@
 import Vue from 'vue';
 
 import WeeksHeaderItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_item.vue';
+import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeWeeks, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate, mockShellWidth, mockItemWidth } from 'ee_spec/roadmap/mock_data';
 
 const mockTimeframeIndex = 0;
+const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
 
 const createComponent = ({
   timeframeIndex = mockTimeframeIndex,
@@ -38,9 +40,6 @@ describe('WeeksHeaderItemComponent', () => {
       const currentDate = new Date();
 
       expect(vm.currentDate.getDate()).toBe(currentDate.getDate());
-      expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
-        mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
-      );
     });
   });
 
@@ -53,20 +52,37 @@ describe('WeeksHeaderItemComponent', () => {
       });
     });
 
+    describe('lastDayOfCurrentWeek', () => {
+      it('returns date object representing last day of the week as set in `timeframeItem`', () => {
+        expect(vm.lastDayOfCurrentWeek.getDate()).toBe(
+          mockTimeframeWeeks[mockTimeframeIndex].getDate() + 7,
+        );
+      });
+    });
+
     describe('timelineHeaderLabel', () => {
-      it('returns string containing Year, Month and Date for current timeline header item', () => {
+      it('returns string containing Year, Month and Date for first timeframe item of the entire timeframe', () => {
         vm = createComponent({});
 
-        expect(vm.timelineHeaderLabel).toBe('2017 Dec 24');
+        expect(vm.timelineHeaderLabel).toBe('2017 Dec 17');
+      });
+
+      it('returns string containing Year, Month and Date for timeframe item when it is first week of the year', () => {
+        vm = createComponent({
+          timeframeIndex: 3,
+          timeframeItem: new Date(2019, 0, 6),
+        });
+
+        expect(vm.timelineHeaderLabel).toBe('2019 Jan 6');
       });
 
-      it('returns string containing only Month and Date for current timeline header item when previous header contained Year', () => {
+      it('returns string containing only Month and Date timeframe item when it is somewhere in the middle of timeframe', () => {
         vm = createComponent({
           timeframeIndex: mockTimeframeIndex + 1,
           timeframeItem: mockTimeframeWeeks[mockTimeframeIndex + 1],
         });
 
-        expect(vm.timelineHeaderLabel).toBe('Dec 31');
+        expect(vm.timelineHeaderLabel).toBe('Dec 24');
       });
     });
 
@@ -112,7 +128,7 @@ describe('WeeksHeaderItemComponent', () => {
       const itemLabelEl = vm.$el.querySelector('.item-label');
 
       expect(itemLabelEl).not.toBeNull();
-      expect(itemLabelEl.innerText.trim()).toBe('2017 Dec 24');
+      expect(itemLabelEl.innerText.trim()).toBe('2017 Dec 17');
     });
   });
 });
diff --git a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js
index f8a8af1a3c57..6d5a7a4dad4a 100644
--- a/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js
+++ b/ee/spec/javascripts/roadmap/components/preset_weeks/weeks_header_sub_item_spec.js
@@ -1,9 +1,12 @@
 import Vue from 'vue';
 
 import WeeksHeaderSubItemComponent from 'ee/roadmap/components/preset_weeks/weeks_header_sub_item.vue';
+import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeWeeks } from 'ee_spec/roadmap/mock_data';
+import { mockTimeframeInitialDate } from 'ee_spec/roadmap/mock_data';
+
+const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
 
 const createComponent = ({
   currentDate = mockTimeframeWeeks[0],
@@ -24,19 +27,19 @@ describe('MonthsHeaderSubItemComponent', () => {
     vm.$destroy();
   });
 
-  describe('data', () => {
-    it('sets prop `headerSubItems` with array of dates containing days of week from timeframeItem', () => {
-      vm = createComponent({});
+  describe('computed', () => {
+    describe('headerSubItems', () => {
+      it('returns `headerSubItems` array of dates containing days of week from timeframeItem', () => {
+        vm = createComponent({});
 
-      expect(Array.isArray(vm.headerSubItems)).toBe(true);
-      expect(vm.headerSubItems.length).toBe(7);
-      vm.headerSubItems.forEach(subItem => {
-        expect(subItem instanceof Date).toBe(true);
+        expect(Array.isArray(vm.headerSubItems)).toBe(true);
+        expect(vm.headerSubItems.length).toBe(7);
+        vm.headerSubItems.forEach(subItem => {
+          expect(subItem instanceof Date).toBe(true);
+        });
       });
     });
-  });
 
-  describe('computed', () => {
     describe('hasToday', () => {
       it('returns true when current week is same as timeframe week', () => {
         vm = createComponent({});
diff --git a/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js b/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js
index d1a1375fb3f4..c9d5a91a197e 100644
--- a/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js
+++ b/ee/spec/javascripts/roadmap/components/roadmap_shell_spec.js
@@ -2,11 +2,14 @@ import Vue from 'vue';
 
 import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
 import eventHub from 'ee/roadmap/event_hub';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockEpic, mockTimeframeMonths, mockGroupId, mockScrollBarSize } from '../mock_data';
+import { mockEpic, mockTimeframeInitialDate, mockGroupId, mockScrollBarSize } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = (
   { epics = [mockEpic], timeframe = mockTimeframeMonths, currentGroupId = mockGroupId },
@@ -88,14 +91,29 @@ describe('RoadmapShellComponent', () => {
 
     describe('handleScroll', () => {
       beforeEach(() => {
-        spyOn(eventHub, '$emit');
+        document.body.innerHTML +=
+          '<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
       });
 
-      it('emits `epicsListScrolled` event via eventHub', () => {
-        vm.noScroll = false;
-        vm.handleScroll();
+      afterEach(() => {
+        document.querySelector('.roadmap-container').remove();
+      });
+
+      it('emits `epicsListScrolled` event via eventHub', done => {
+        const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
+        spyOn(eventHub, '$emit');
+
+        Vue.nextTick()
+          .then(() => {
+            vmWithParentEl.noScroll = false;
+            vmWithParentEl.handleScroll();
+
+            expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
 
-        expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Object));
+            vmWithParentEl.$destroy();
+          })
+          .then(done)
+          .catch(done.fail);
       });
     });
   });
diff --git a/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js b/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js
index 246c540f8579..1cb3369f6d39 100644
--- a/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js
+++ b/ee/spec/javascripts/roadmap/components/roadmap_timeline_section_spec.js
@@ -2,11 +2,14 @@ import Vue from 'vue';
 
 import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
 import eventHub from 'ee/roadmap/event_hub';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockEpic, mockTimeframeMonths, mockShellWidth } from '../mock_data';
+import { mockEpic, mockTimeframeInitialDate, mockShellWidth } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.MONTHS,
diff --git a/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js b/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js
index 0ad21a725d57..12f07560e4b1 100644
--- a/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js
+++ b/ee/spec/javascripts/roadmap/components/timeline_today_indicator_spec.js
@@ -2,10 +2,14 @@ import Vue from 'vue';
 
 import timelineTodayIndicatorComponent from 'ee/roadmap/components/timeline_today_indicator.vue';
 import eventHub from 'ee/roadmap/event_hub';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeMonths } from '../mock_data';
+import { mockTimeframeInitialDate } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const mockCurrentDate = new Date(
   mockTimeframeMonths[0].getFullYear(),
@@ -53,29 +57,33 @@ describe('TimelineTodayIndicatorComponent', () => {
         const stylesObj = vm.todayBarStyles;
 
         expect(stylesObj.height).toBe('120px');
-        expect(stylesObj.left).toBe('48%');
+        expect(stylesObj.left).toBe('50%');
         expect(vm.todayBarReady).toBe(true);
       });
     });
   });
 
   describe('mounted', () => {
-    it('binds `epicsListRendered` event listener via eventHub', () => {
+    it('binds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
       spyOn(eventHub, '$on');
       const vmX = createComponent({});
 
       expect(eventHub.$on).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
+      expect(eventHub.$on).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
+      expect(eventHub.$on).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
       vmX.$destroy();
     });
   });
 
   describe('beforeDestroy', () => {
-    it('unbinds `epicsListRendered` event listener via eventHub', () => {
+    it('unbinds `epicsListRendered`, `epicsListScrolled` and `refreshTimeline` event listeners via eventHub', () => {
       spyOn(eventHub, '$off');
       const vmX = createComponent({});
       vmX.$destroy();
 
       expect(eventHub.$off).toHaveBeenCalledWith('epicsListRendered', jasmine.any(Function));
+      expect(eventHub.$off).toHaveBeenCalledWith('epicsListScrolled', jasmine.any(Function));
+      expect(eventHub.$off).toHaveBeenCalledWith('refreshTimeline', jasmine.any(Function));
     });
   });
 
diff --git a/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js
index ec2bd35090b9..9eca1073609a 100644
--- a/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js
+++ b/ee/spec/javascripts/roadmap/mixins/months_preset_mixin_spec.js
@@ -1,10 +1,14 @@
 import Vue from 'vue';
 
 import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeMonths, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.MONTHS,
@@ -114,17 +118,6 @@ describe('MonthsPresetMixin', () => {
         expect(vm.getTimelineBarStartOffsetForMonths()).toBe('left: 0;');
       });
 
-      it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            startDate: mockTimeframeMonths[mockTimeframeMonths.length - 1],
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarStartOffsetForMonths()).toBe('right: 8px;');
-      });
-
       it('returns proportional `left` value based on Epic startDate and days in the month', () => {
         vm = createComponent({
           epic: Object.assign({}, mockEpic, {
@@ -132,7 +125,7 @@ describe('MonthsPresetMixin', () => {
           }),
         });
 
-        expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 48');
+        expect(vm.getTimelineBarStartOffsetForMonths()).toContain('left: 50%');
       });
     });
 
@@ -147,7 +140,7 @@ describe('MonthsPresetMixin', () => {
           }),
         });
 
-        expect(Math.floor(vm.getTimelineBarWidthForMonths())).toBe(492);
+        expect(Math.floor(vm.getTimelineBarWidthForMonths())).toBe(637);
       });
     });
   });
diff --git a/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js
index a047cd1f6127..360606a6f701 100644
--- a/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js
+++ b/ee/spec/javascripts/roadmap/mixins/quarters_preset_mixin_spec.js
@@ -1,10 +1,14 @@
 import Vue from 'vue';
 
 import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
+import { getTimeframeForQuartersView } from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeQuarters, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+
+const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.QUARTERS,
@@ -114,17 +118,6 @@ describe('QuartersPresetMixin', () => {
         expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('left: 0;');
       });
 
-      it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            startDate: mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[1],
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarStartOffsetForQuarters()).toBe('right: 8px;');
-      });
-
       it('returns proportional `left` value based on Epic startDate and days in the quarter', () => {
         vm = createComponent({
           epic: Object.assign({}, mockEpic, {
@@ -147,7 +140,7 @@ describe('QuartersPresetMixin', () => {
           }),
         });
 
-        expect(Math.floor(vm.getTimelineBarWidthForQuarters())).toBe(282);
+        expect(Math.floor(vm.getTimelineBarWidthForQuarters())).toBe(240);
       });
     });
   });
diff --git a/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js
index 9bc67f011cde..7698b3a77016 100644
--- a/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js
+++ b/ee/spec/javascripts/roadmap/mixins/section_mixin_spec.js
@@ -1,11 +1,19 @@
 import Vue from 'vue';
 
 import roadmapTimelineSectionComponent from 'ee/roadmap/components/roadmap_timeline_section.vue';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
 
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockEpic, mockTimeframeMonths, mockShellWidth, mockScrollBarSize } from '../mock_data';
+import {
+  mockEpic,
+  mockTimeframeInitialDate,
+  mockShellWidth,
+  mockScrollBarSize,
+} from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.MONTHS,
@@ -52,7 +60,7 @@ describe('SectionMixin', () => {
 
     describe('sectionItemWidth', () => {
       it('returns calculated item width based on sectionShellWidth and timeframe size', () => {
-        expect(vm.sectionItemWidth).toBe(240);
+        expect(vm.sectionItemWidth).toBe(210);
       });
     });
 
diff --git a/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js b/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js
index 3ac996bc907f..83a23a9299f1 100644
--- a/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js
+++ b/ee/spec/javascripts/roadmap/mixins/weeks_preset_mixin_spec.js
@@ -1,10 +1,14 @@
 import Vue from 'vue';
 
 import EpicItemTimelineComponent from 'ee/roadmap/components/epic_item_timeline.vue';
+import { getTimeframeForWeeksView } from 'ee/roadmap/utils/roadmap_utils';
+
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
 import mountComponent from 'spec/helpers/vue_mount_component_helper';
-import { mockTimeframeWeeks, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+import { mockTimeframeInitialDate, mockEpic, mockShellWidth, mockItemWidth } from '../mock_data';
+
+const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
 
 const createComponent = ({
   presetType = PRESET_TYPES.WEEKS,
@@ -59,7 +63,7 @@ describe('WeeksPresetMixin', () => {
         vm = createComponent({});
         const lastDayOfWeek = vm.getLastDayOfWeek(mockTimeframeWeeks[0]);
 
-        expect(lastDayOfWeek.getDate()).toBe(30);
+        expect(lastDayOfWeek.getDate()).toBe(23);
         expect(lastDayOfWeek.getMonth()).toBe(11);
         expect(lastDayOfWeek.getFullYear()).toBe(2017);
       });
@@ -95,14 +99,6 @@ describe('WeeksPresetMixin', () => {
       });
     });
 
-    describe('getTimelineBarEndOffsetHalfForWeek', () => {
-      it('returns timeline bar end offset for Weeks view', () => {
-        vm = createComponent({});
-
-        expect(vm.getTimelineBarEndOffsetHalfForWeek()).toBe(28);
-      });
-    });
-
     describe('getTimelineBarStartOffsetForWeeks', () => {
       it('returns empty string when Epic startDate is out of range', () => {
         vm = createComponent({
@@ -133,19 +129,6 @@ describe('WeeksPresetMixin', () => {
         expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('left: 0;');
       });
 
-      it('returns `right: 8px;` when Epic startDate is in last timeframe month and endDate is out of range', () => {
-        const startDate = new Date(mockTimeframeWeeks[mockTimeframeWeeks.length - 1].getTime());
-        startDate.setDate(startDate.getDate() + 1);
-        vm = createComponent({
-          epic: Object.assign({}, mockEpic, {
-            startDate,
-            endDateOutOfRange: true,
-          }),
-        });
-
-        expect(vm.getTimelineBarStartOffsetForWeeks()).toBe('right: 8px;');
-      });
-
       it('returns proportional `left` value based on Epic startDate and days in the month', () => {
         vm = createComponent({
           epic: Object.assign({}, mockEpic, {
@@ -153,7 +136,7 @@ describe('WeeksPresetMixin', () => {
           }),
         });
 
-        expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 60');
+        expect(vm.getTimelineBarStartOffsetForWeeks()).toContain('left: 51');
       });
     });
 
@@ -168,7 +151,7 @@ describe('WeeksPresetMixin', () => {
           }),
         });
 
-        expect(Math.floor(vm.getTimelineBarWidthForWeeks())).toBe(1600);
+        expect(Math.floor(vm.getTimelineBarWidthForWeeks())).toBe(1611);
       });
     });
   });
diff --git a/ee/spec/javascripts/roadmap/mock_data.js b/ee/spec/javascripts/roadmap/mock_data.js
index 799a743b4924..56c32ea02782 100644
--- a/ee/spec/javascripts/roadmap/mock_data.js
+++ b/ee/spec/javascripts/roadmap/mock_data.js
@@ -1,9 +1,3 @@
-import {
-  getTimeframeForQuartersView,
-  getTimeframeForMonthsView,
-  getTimeframeForWeeksView,
-} from 'ee/roadmap/utils/roadmap_utils';
-
 export const mockScrollBarSize = 15;
 
 export const mockGroupId = 2;
@@ -12,15 +6,85 @@ export const mockShellWidth = 2000;
 
 export const mockItemWidth = 180;
 
+export const mockSortedBy = 'start_date_asc';
+
+export const basePath = '/groups/gitlab-org/-/epics.json';
+
 export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30';
 
 export const mockNewEpicEndpoint = '/groups/gitlab-org/-/epics';
 
 export const mockSvgPath = '/foo/bar.svg';
 
-export const mockTimeframeQuarters = getTimeframeForQuartersView(new Date(2018, 0, 1));
-export const mockTimeframeMonths = getTimeframeForMonthsView(new Date(2018, 0, 1));
-export const mockTimeframeWeeks = getTimeframeForWeeksView(new Date(2018, 0, 1));
+export const mockTimeframeInitialDate = new Date(2018, 0, 1);
+
+export const mockTimeframeQuartersPrepend = [
+  {
+    year: 2016,
+    quarterSequence: 4,
+    range: [new Date(2016, 9, 1), new Date(2016, 10, 1), new Date(2016, 11, 31)],
+  },
+  {
+    year: 2017,
+    quarterSequence: 1,
+    range: [new Date(2017, 0, 1), new Date(2017, 1, 1), new Date(2017, 2, 31)],
+  },
+  {
+    year: 2017,
+    quarterSequence: 2,
+    range: [new Date(2017, 3, 1), new Date(2017, 4, 1), new Date(2017, 5, 30)],
+  },
+];
+export const mockTimeframeQuartersAppend = [
+  {
+    year: 2019,
+    quarterSequence: 2,
+    range: [new Date(2019, 3, 1), new Date(2019, 4, 1), new Date(2019, 5, 30)],
+  },
+  {
+    year: 2019,
+    quarterSequence: 3,
+    range: [new Date(2019, 6, 1), new Date(2019, 7, 1), new Date(2019, 8, 30)],
+  },
+  {
+    year: 2019,
+    quarterSequence: 4,
+    range: [new Date(2019, 9, 1), new Date(2019, 10, 1), new Date(2019, 11, 31)],
+  },
+];
+
+export const mockTimeframeMonthsPrepend = [
+  new Date(2017, 4, 1),
+  new Date(2017, 5, 1),
+  new Date(2017, 6, 1),
+  new Date(2017, 7, 1),
+  new Date(2017, 8, 1),
+  new Date(2017, 9, 1),
+];
+export const mockTimeframeMonthsAppend = [
+  new Date(2018, 6, 1),
+  new Date(2018, 7, 1),
+  new Date(2018, 8, 1),
+  new Date(2018, 9, 1),
+  new Date(2018, 10, 30),
+];
+
+export const mockTimeframeWeeksPrepend = [
+  new Date(2017, 10, 5),
+  new Date(2017, 10, 12),
+  new Date(2017, 10, 19),
+  new Date(2017, 10, 26),
+  new Date(2017, 11, 3),
+  new Date(2017, 11, 10),
+];
+export const mockTimeframeWeeksAppend = [
+  new Date(2018, 0, 28),
+  new Date(2018, 1, 4),
+  new Date(2018, 1, 11),
+  new Date(2018, 1, 18),
+  new Date(2018, 1, 25),
+  new Date(2018, 2, 4),
+];
 
 export const mockEpic = {
   id: 1,
@@ -188,3 +252,22 @@ export const rawEpics = [
     web_url: '/groups/gitlab-org/marketing/-/epics/22',
   },
 ];
+
+export const mockUnsortedEpics = [
+  {
+    startDate: new Date(2017, 2, 12),
+    endDate: new Date(2017, 7, 20),
+  },
+  {
+    startDate: new Date(2015, 5, 8),
+    endDate: new Date(2016, 3, 1),
+  },
+  {
+    startDate: new Date(2019, 4, 12),
+    endDate: new Date(2019, 7, 30),
+  },
+  {
+    startDate: new Date(2014, 3, 17),
+    endDate: new Date(2015, 7, 15),
+  },
+];
diff --git a/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js b/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js
index 9f68ecf41f51..f374698bee7a 100644
--- a/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js
+++ b/ee/spec/javascripts/roadmap/service/roadmap_service_spec.js
@@ -1,13 +1,23 @@
 import axios from '~/lib/utils/axios_utils';
 
 import RoadmapService from 'ee/roadmap/service/roadmap_service';
-import { epicsPath } from '../mock_data';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
+import { PRESET_TYPES } from 'ee/roadmap/constants';
+import { basePath, epicsPath, mockTimeframeInitialDate } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 describe('RoadmapService', () => {
   let service;
 
   beforeEach(() => {
-    service = new RoadmapService(epicsPath);
+    service = new RoadmapService({
+      initialEpicsPath: epicsPath,
+      epicsState: 'all',
+      filterQueryString: '',
+      basePath,
+    });
   });
 
   describe('getEpics', () => {
@@ -15,7 +25,30 @@ describe('RoadmapService', () => {
       spyOn(axios, 'get').and.stub();
       service.getEpics();
 
-      expect(axios.get).toHaveBeenCalledWith(service.epicsPath);
+      expect(axios.get).toHaveBeenCalledWith(
+        '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30',
+      );
+    });
+  });
+
+  describe('getEpicsForTimeframe', () => {
+    it('calls `getEpicsPathForPreset` to construct epics path', () => {
+      const getEpicsPathSpy = spyOnDependency(RoadmapService, 'getEpicsPathForPreset');
+      spyOn(axios, 'get').and.stub();
+
+      const presetType = PRESET_TYPES.MONTHS;
+
+      service.getEpicsForTimeframe(presetType, mockTimeframeMonths);
+
+      expect(getEpicsPathSpy).toHaveBeenCalledWith(
+        jasmine.objectContaining({
+          timeframe: mockTimeframeMonths,
+          epicsState: 'all',
+          filterQueryString: '',
+          basePath,
+          presetType,
+        }),
+      );
     });
   });
 });
diff --git a/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js b/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js
index 0754354896cb..1ad1f29269b1 100644
--- a/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js
+++ b/ee/spec/javascripts/roadmap/store/roadmap_store_spec.js
@@ -1,21 +1,32 @@
 import RoadmapStore from 'ee/roadmap/store/roadmap_store';
-import { PRESET_TYPES } from 'ee/roadmap/constants';
-import { mockGroupId, mockTimeframeMonths, rawEpics } from '../mock_data';
+import { getTimeframeForMonthsView } from 'ee/roadmap/utils/roadmap_utils';
+
+import { PRESET_TYPES, EXTEND_AS } from 'ee/roadmap/constants';
+import { mockGroupId, mockTimeframeInitialDate, rawEpics, mockSortedBy } from '../mock_data';
+
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
 
 describe('RoadmapStore', () => {
   let store;
 
   beforeEach(() => {
-    store = new RoadmapStore(mockGroupId, mockTimeframeMonths, PRESET_TYPES.MONTHS);
+    store = new RoadmapStore({
+      groupId: mockGroupId,
+      timeframe: mockTimeframeMonths,
+      presetType: PRESET_TYPES.MONTHS,
+      sortedBy: mockSortedBy,
+    });
   });
 
   describe('constructor', () => {
     it('initializes default state', () => {
       expect(store.state).toBeDefined();
       expect(Array.isArray(store.state.epics)).toBe(true);
+      expect(Array.isArray(store.state.epicIds)).toBe(true);
       expect(store.state.currentGroupId).toBe(mockGroupId);
       expect(store.state.timeframe).toBe(mockTimeframeMonths);
       expect(store.presetType).toBe(PRESET_TYPES.MONTHS);
+      expect(store.sortedBy).toBe(mockSortedBy);
       expect(store.timeframeStartDate).toBeDefined();
       expect(store.timeframeEndDate).toBeDefined();
     });
@@ -23,9 +34,52 @@ describe('RoadmapStore', () => {
 
   describe('setEpics', () => {
     it('sets Epics list to state while filtering out Epics with invalid dates', () => {
+      spyOn(RoadmapStore, 'filterInvalidEpics').and.callThrough();
       store.setEpics(rawEpics);
 
-      expect(store.getEpics().length).toBe(rawEpics.length - 2); // 2 epics have invalid dates
+      expect(RoadmapStore.filterInvalidEpics).toHaveBeenCalledWith(
+        jasmine.objectContaining({
+          timeframeStartDate: store.timeframeStartDate,
+          timeframeEndDate: store.timeframeEndDate,
+          state: store.state,
+          epics: rawEpics,
+        }),
+      );
+
+      expect(store.getEpics().length).toBe(rawEpics.length - 1); // There is only 1 invalid epic
+    });
+  });
+
+  describe('addEpics', () => {
+    beforeEach(() => {
+      store.setEpics(rawEpics);
+    });
+
+    it('adds new Epics to the epics list within state while filtering out existing epics or epics with invalid dates and sorts the list based on `sortedBy` value', () => {
+      spyOn(RoadmapStore, 'filterInvalidEpics');
+      const sortEpicsSpy = spyOnDependency(RoadmapStore, 'sortEpics').and.stub();
+
+      const newEpic = Object.assign({}, rawEpics[0], {
+        id: 999,
+      });
+
+      store.addEpics([newEpic]);
+
+      expect(RoadmapStore.filterInvalidEpics).toHaveBeenCalledWith(
+        jasmine.objectContaining({
+          timeframeStartDate: store.timeframeStartDate,
+          timeframeEndDate: store.timeframeEndDate,
+          state: store.state,
+          epics: [newEpic],
+          newEpic: true,
+        }),
+      );
+
+      expect(sortEpicsSpy).toHaveBeenCalledWith(jasmine.any(Object), mockSortedBy);
+
+      // rawEpics contain 2 invalid epics but now that we added 1
+      // new epic, length will be `rawEpics.length - 1 (invalid) + 1 (new epic)`
+      expect(store.getEpics().length).toBe(rawEpics.length);
     });
   });
 
@@ -41,12 +95,98 @@ describe('RoadmapStore', () => {
     });
   });
 
+  describe('extendTimeframe', () => {
+    beforeEach(() => {
+      store.setEpics(rawEpics);
+      store.state.timeframe = [];
+    });
+
+    it('calls `extendTimeframeForPreset` and prepends items to the timeframe when called with `extendAs` param as `prepend`', () => {
+      const extendTimeframeSpy = spyOnDependency(
+        RoadmapStore,
+        'extendTimeframeForPreset',
+      ).and.returnValue([]);
+      spyOn(store.state.timeframe, 'unshift');
+      spyOn(store, 'initTimeframeThreshold');
+      spyOn(RoadmapStore, 'processEpicDates');
+
+      const itemCount = store.extendTimeframe(EXTEND_AS.PREPEND).length;
+
+      expect(extendTimeframeSpy).toHaveBeenCalledWith(jasmine.any(Object));
+      expect(store.state.timeframe.unshift).toHaveBeenCalled();
+      expect(store.initTimeframeThreshold).toHaveBeenCalled();
+      expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
+      expect(itemCount).toBe(0);
+    });
+
+    it('calls `extendTimeframeForPreset` and appends items to the timeframe when called with `extendAs` param as `append`', () => {
+      const extendTimeframeSpy = spyOnDependency(
+        RoadmapStore,
+        'extendTimeframeForPreset',
+      ).and.returnValue([]);
+      spyOn(store.state.timeframe, 'push');
+      spyOn(store, 'initTimeframeThreshold');
+      spyOn(RoadmapStore, 'processEpicDates');
+
+      const itemCount = store.extendTimeframe(EXTEND_AS.APPEND).length;
+
+      expect(extendTimeframeSpy).toHaveBeenCalledWith(jasmine.any(Object));
+      expect(store.state.timeframe.push).toHaveBeenCalled();
+      expect(store.initTimeframeThreshold).toHaveBeenCalled();
+      expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
+      expect(itemCount).toBe(0);
+    });
+  });
+
+  describe('filterInvalidEpics', () => {
+    it('returns formatted epics list by filtering out epics with invalid dates', () => {
+      spyOn(RoadmapStore, 'formatEpicDetails').and.callThrough();
+
+      const epicsList = RoadmapStore.filterInvalidEpics({
+        epics: rawEpics,
+        timeframeStartDate: store.timeframeStartDate,
+        timeframeEndDate: store.timeframeEndDate,
+        state: store.state,
+      });
+
+      expect(RoadmapStore.formatEpicDetails).toHaveBeenCalled();
+
+      expect(epicsList.length).toBe(rawEpics.length - 1); // There are is only 1 invalid epic
+    });
+
+    it('returns formatted epics list by filtering out existing epics', () => {
+      store.setEpics(rawEpics);
+
+      spyOn(RoadmapStore, 'formatEpicDetails').and.callThrough();
+
+      const newEpic = Object.assign({}, rawEpics[0]);
+
+      const epicsList = RoadmapStore.filterInvalidEpics({
+        epics: [newEpic],
+        timeframeStartDate: store.timeframeStartDate,
+        timeframeEndDate: store.timeframeEndDate,
+        state: store.state,
+      });
+
+      expect(RoadmapStore.formatEpicDetails).toHaveBeenCalled();
+
+      expect(epicsList.length).toBe(0); // No epics are eligible to be added
+    });
+  });
+
   describe('formatEpicDetails', () => {
     const rawEpic = rawEpics[0];
 
     it('returns formatted Epic object from raw Epic object', () => {
-      const epic = RoadmapStore.formatEpicDetails(rawEpic);
+      spyOn(RoadmapStore, 'processEpicDates');
+
+      const epic = RoadmapStore.formatEpicDetails(
+        rawEpic,
+        store.timeframeStartDate,
+        store.timeframeEndDate,
+      );
 
+      expect(RoadmapStore.processEpicDates).toHaveBeenCalled();
       expect(epic.id).toBe(rawEpic.id);
       expect(epic.name).toBe(rawEpic.name);
       expect(epic.groupId).toBe(rawEpic.group_id);
diff --git a/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js b/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js
index 94b85b2473c0..b41722b52c53 100644
--- a/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js
+++ b/ee/spec/javascripts/roadmap/utils/roadmap_utils_spec.js
@@ -1,12 +1,32 @@
 import {
   getTimeframeForQuartersView,
+  extendTimeframeForQuartersView,
   getTimeframeForMonthsView,
+  extendTimeframeForMonthsView,
   getTimeframeForWeeksView,
+  extendTimeframeForWeeksView,
+  extendTimeframeForAvailableWidth,
   getEpicsPathForPreset,
+  sortEpics,
 } from 'ee/roadmap/utils/roadmap_utils';
 
 import { PRESET_TYPES } from 'ee/roadmap/constants';
 
+import {
+  mockTimeframeInitialDate,
+  mockTimeframeQuartersPrepend,
+  mockTimeframeQuartersAppend,
+  mockTimeframeMonthsPrepend,
+  mockTimeframeMonthsAppend,
+  mockTimeframeWeeksPrepend,
+  mockTimeframeWeeksAppend,
+  mockUnsortedEpics,
+} from '../mock_data';
+
+const mockTimeframeQuarters = getTimeframeForQuartersView(mockTimeframeInitialDate);
+const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
+const mockTimeframeWeeks = getTimeframeForWeeksView(mockTimeframeInitialDate);
+
 describe('getTimeframeForQuartersView', () => {
   let timeframe;
 
@@ -14,8 +34,8 @@ describe('getTimeframeForQuartersView', () => {
     timeframe = getTimeframeForQuartersView(new Date(2018, 0, 1));
   });
 
-  it('returns timeframe with total of 6 quarters', () => {
-    expect(timeframe.length).toBe(6);
+  it('returns timeframe with total of 7 quarters', () => {
+    expect(timeframe.length).toBe(7);
   });
 
   it('each timeframe item has `quarterSequence`, `year` and `range` present', () => {
@@ -26,15 +46,15 @@ describe('getTimeframeForQuartersView', () => {
     expect(Array.isArray(timeframeItem.range)).toBe(true);
   });
 
-  it('first timeframe item refers to quarter prior to current quarter', () => {
+  it('first timeframe item refers to 2 quarters prior to current quarter', () => {
     const timeframeItem = timeframe[0];
     const expectedQuarter = {
-      0: { month: 9, date: 1 }, // 1 Oct 2017
-      1: { month: 10, date: 1 }, // 1 Nov 2017
-      2: { month: 11, date: 31 }, // 31 Dec 2017
+      0: { month: 6, date: 1 }, // 1 Jul 2017
+      1: { month: 7, date: 1 }, // 1 Aug 2017
+      2: { month: 8, date: 30 }, // 30 Sep 2017
     };
 
-    expect(timeframeItem.quarterSequence).toEqual(4);
+    expect(timeframeItem.quarterSequence).toEqual(3);
     expect(timeframeItem.year).toEqual(2017);
     timeframeItem.range.forEach((month, index) => {
       expect(month.getFullYear()).toBe(2017);
@@ -61,6 +81,44 @@ describe('getTimeframeForQuartersView', () => {
   });
 });
 
+describe('extendTimeframeForQuartersView', () => {
+  it('returns extended timeframe into the past from current timeframe startDate', () => {
+    const initialDate = mockTimeframeQuarters[0].range[0];
+
+    const extendedTimeframe = extendTimeframeForQuartersView(initialDate, -9);
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeQuartersPrepend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.year).toBe(mockTimeframeQuartersPrepend[index].year);
+      expect(timeframeItem.quarterSequence).toBe(
+        mockTimeframeQuartersPrepend[index].quarterSequence,
+      );
+
+      timeframeItem.range.forEach((rangeItem, j) => {
+        expect(rangeItem.getTime()).toBe(mockTimeframeQuartersPrepend[index].range[j].getTime());
+      });
+    });
+  });
+
+  it('returns extended timeframe into the future from current timeframe endDate', () => {
+    const initialDate = mockTimeframeQuarters[mockTimeframeQuarters.length - 1].range[2];
+
+    const extendedTimeframe = extendTimeframeForQuartersView(initialDate, 9);
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeQuartersAppend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.year).toBe(mockTimeframeQuartersAppend[index].year);
+      expect(timeframeItem.quarterSequence).toBe(
+        mockTimeframeQuartersAppend[index].quarterSequence,
+      );
+
+      timeframeItem.range.forEach((rangeItem, j) => {
+        expect(rangeItem.getTime()).toBe(mockTimeframeQuartersAppend[index].range[j].getTime());
+      });
+    });
+  });
+});
+
 describe('getTimeframeForMonthsView', () => {
   let timeframe;
 
@@ -68,15 +126,15 @@ describe('getTimeframeForMonthsView', () => {
     timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
   });
 
-  it('returns timeframe with total of 7 months', () => {
-    expect(timeframe.length).toBe(7);
+  it('returns timeframe with total of 8 months', () => {
+    expect(timeframe.length).toBe(8);
   });
 
-  it('first timeframe item refers to month prior to current month', () => {
+  it('first timeframe item refers to 2 months prior to current month', () => {
     const timeframeItem = timeframe[0];
     const expectedMonth = {
       year: 2017,
-      month: 11,
+      month: 10,
       date: 1,
     };
 
@@ -99,6 +157,28 @@ describe('getTimeframeForMonthsView', () => {
   });
 });
 
+describe('extendTimeframeForMonthsView', () => {
+  it('returns extended timeframe into the past from current timeframe startDate', () => {
+    const initialDate = mockTimeframeMonths[0];
+    const extendedTimeframe = extendTimeframeForMonthsView(initialDate, -6);
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeMonthsPrepend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsPrepend[index].getTime());
+    });
+  });
+
+  it('returns extended timeframe into the future from current timeframe endDate', () => {
+    const initialDate = mockTimeframeMonths[mockTimeframeMonths.length - 1];
+    const extendedTimeframe = extendTimeframeForMonthsView(initialDate, 7);
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeMonthsAppend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.getTime()).toBe(mockTimeframeMonthsAppend[index].getTime());
+    });
+  });
+});
+
 describe('getTimeframeForWeeksView', () => {
   let timeframe;
 
@@ -106,16 +186,16 @@ describe('getTimeframeForWeeksView', () => {
     timeframe = getTimeframeForWeeksView(new Date(2018, 0, 1));
   });
 
-  it('returns timeframe with total of 6 weeks', () => {
-    expect(timeframe.length).toBe(6);
+  it('returns timeframe with total of 7 weeks', () => {
+    expect(timeframe.length).toBe(7);
   });
 
-  it('first timeframe item refers to week prior to current week', () => {
+  it('first timeframe item refers to 2 weeks prior to current week', () => {
     const timeframeItem = timeframe[0];
     const expectedMonth = {
       year: 2017,
       month: 11,
-      date: 24,
+      date: 17,
     };
 
     expect(timeframeItem.getFullYear()).toBe(expectedMonth.year);
@@ -137,6 +217,67 @@ describe('getTimeframeForWeeksView', () => {
   });
 });
 
+describe('extendTimeframeForWeeksView', () => {
+  it('returns extended timeframe into the past from current timeframe startDate', () => {
+    const extendedTimeframe = extendTimeframeForWeeksView(mockTimeframeWeeks[0], -6); // initialDate: 17 Dec 2017
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeWeeksPrepend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksPrepend[index].getTime());
+    });
+  });
+
+  it('returns extended timeframe into the future from current timeframe endDate', () => {
+    const extendedTimeframe = extendTimeframeForWeeksView(
+      mockTimeframeWeeks[mockTimeframeWeeks.length - 1], // initialDate: 28 Jan 2018
+      6,
+    );
+
+    expect(extendedTimeframe.length).toBe(mockTimeframeWeeksAppend.length);
+    extendedTimeframe.forEach((timeframeItem, index) => {
+      expect(timeframeItem.getTime()).toBe(mockTimeframeWeeksAppend[index].getTime());
+    });
+  });
+});
+
+describe('extendTimeframeForAvailableWidth', () => {
+  let timeframe;
+  let timeframeStart;
+  let timeframeEnd;
+
+  beforeEach(() => {
+    timeframe = mockTimeframeMonths.slice();
+    [timeframeStart] = timeframe;
+    timeframeEnd = timeframe[timeframe.length - 1];
+  });
+
+  it('should not extend `timeframe` when availableTimeframeWidth is small enough to force horizontal scrollbar to show up', () => {
+    extendTimeframeForAvailableWidth({
+      availableTimeframeWidth: 100,
+      presetType: PRESET_TYPES.MONTHS,
+      timeframe,
+      timeframeStart,
+      timeframeEnd,
+    });
+
+    expect(timeframe.length).toBe(mockTimeframeMonths.length);
+  });
+
+  it('should extend `timeframe` when availableTimeframeWidth is large enough that it can fit more timeframe items to show up horizontal scrollbar', () => {
+    extendTimeframeForAvailableWidth({
+      availableTimeframeWidth: 2000,
+      presetType: PRESET_TYPES.MONTHS,
+      timeframe,
+      timeframeStart,
+      timeframeEnd,
+    });
+
+    expect(timeframe.length).toBe(16);
+    expect(timeframe[0].getTime()).toBe(1498867200000); // 1 July 2017
+    expect(timeframe[timeframe.length - 1].getTime()).toBe(1540944000000); // 31 Oct 2018
+  });
+});
+
 describe('getEpicsPathForPreset', () => {
   const basePath = '/groups/gitlab-org/-/epics.json';
   const filterQueryString = 'scope=all&utf8=✓&state=opened&label_name[]=Bug';
@@ -149,7 +290,7 @@ describe('getEpicsPathForPreset', () => {
       presetType: PRESET_TYPES.QUARTERS,
     });
 
-    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-10-1&end_date=2019-3-31`);
+    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-7-1&end_date=2019-3-31`);
   });
 
   it('returns epics path string based on provided basePath and timeframe for Months', () => {
@@ -160,7 +301,7 @@ describe('getEpicsPathForPreset', () => {
       presetType: PRESET_TYPES.MONTHS,
     });
 
-    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30`);
+    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30`);
   });
 
   it('returns epics path string based on provided basePath and timeframe for Weeks', () => {
@@ -171,7 +312,7 @@ describe('getEpicsPathForPreset', () => {
       presetType: PRESET_TYPES.WEEKS,
     });
 
-    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-24&end_date=2018-2-3`);
+    expect(epicsPath).toBe(`${basePath}?state=all&start_date=2017-12-17&end_date=2018-2-3`);
   });
 
   it('returns epics path string while preserving filterQueryString', () => {
@@ -184,7 +325,94 @@ describe('getEpicsPathForPreset', () => {
     });
 
     expect(epicsPath).toBe(
-      `${basePath}?state=all&start_date=2017-12-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
+      `${basePath}?state=all&start_date=2017-11-1&end_date=2018-6-30&scope=all&utf8=✓&state=opened&label_name[]=Bug`,
     );
   });
+
+  it('returns epics path string containing epicsState', () => {
+    const epicsState = 'opened';
+    const timeframe = getTimeframeForMonthsView(new Date(2018, 0, 1));
+    const epicsPath = getEpicsPathForPreset({
+      presetType: PRESET_TYPES.MONTHS,
+      basePath,
+      timeframe,
+      epicsState,
+    });
+
+    expect(epicsPath).toContain(`state=${epicsState}`);
+  });
+});
+
+describe('sortEpics', () => {
+  it('sorts epics list by startDate in ascending order when `sortedBy` param is `start_date_asc`', () => {
+    const epics = mockUnsortedEpics.slice();
+    const sortedOrder = [
+      new Date(2014, 3, 17),
+      new Date(2015, 5, 8),
+      new Date(2017, 2, 12),
+      new Date(2019, 4, 12),
+    ];
+
+    sortEpics(epics, 'start_date_asc');
+
+    expect(epics.length).toBe(mockUnsortedEpics.length);
+
+    epics.forEach((epic, index) => {
+      expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
+    });
+  });
+
+  it('sorts epics list by startDate in descending order when `sortedBy` param is `start_date_desc`', () => {
+    const epics = mockUnsortedEpics.slice();
+    const sortedOrder = [
+      new Date(2019, 4, 12),
+      new Date(2017, 2, 12),
+      new Date(2015, 5, 8),
+      new Date(2014, 3, 17),
+    ];
+
+    sortEpics(epics, 'start_date_desc');
+
+    expect(epics.length).toBe(mockUnsortedEpics.length);
+
+    epics.forEach((epic, index) => {
+      expect(epic.startDate.getTime()).toBe(sortedOrder[index].getTime());
+    });
+  });
+
+  it('sorts epics list by endDate in ascending order when `sortedBy` param is `end_date_asc`', () => {
+    const epics = mockUnsortedEpics.slice();
+    const sortedOrder = [
+      new Date(2015, 7, 15),
+      new Date(2016, 3, 1),
+      new Date(2017, 7, 20),
+      new Date(2019, 7, 30),
+    ];
+
+    sortEpics(epics, 'end_date_asc');
+
+    expect(epics.length).toBe(mockUnsortedEpics.length);
+
+    epics.forEach((epic, index) => {
+      expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
+    });
+  });
+
+  it('sorts epics list by endDate in descending order when `sortedBy` param is `end_date_desc`', () => {
+    const epics = mockUnsortedEpics.slice();
+    const sortedOrder = [
+      new Date(2019, 7, 30),
+      new Date(2017, 7, 20),
+      new Date(2016, 3, 1),
+      new Date(2015, 7, 15),
+    ];
+
+    sortEpics(epics, 'end_date_desc');
+
+    expect(epics.length).toBe(mockUnsortedEpics.length);
+
+    epics.forEach((epic, index) => {
+      expect(epic.endDate.getTime()).toBe(sortedOrder[index].getTime());
+    });
+  });
 });
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 6858c2811b1c..0e9344f1bbb7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4538,22 +4538,10 @@ msgstr ""
 msgid "GroupRoadmap|The roadmap shows the progress of your epics along a timeline"
 msgstr ""
 
-msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}."
+msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups; from %{startDate} to %{endDate}."
 msgstr ""
 
-msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}."
-msgstr ""
-
-msgid "GroupRoadmap|To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}."
-msgstr ""
-
-msgid "GroupRoadmap|To widen your search, change or remove filters. In the months view, only epics in the past month, current month, and next 5 months are shown &ndash; from %{startDate} to %{endDate}."
-msgstr ""
-
-msgid "GroupRoadmap|To widen your search, change or remove filters. In the quarters view, only epics in the past quarter, current quarter, and next 4 quarters are shown &ndash; from %{startDate} to %{endDate}."
-msgstr ""
-
-msgid "GroupRoadmap|To widen your search, change or remove filters. In the weeks view, only epics in the past week, current week, and next 4 weeks are shown &ndash; from %{startDate} to %{endDate}."
+msgid "GroupRoadmap|To widen your search, change or remove filters; from %{startDate} to %{endDate}."
 msgstr ""
 
 msgid "GroupRoadmap|Until %{dateWord}"
-- 
GitLab